Files
Jason Woltje f5792c40be feat: Complete fleet — 94 skills across 10+ domains
Pulled ALL skills from 15 source repositories:
- anthropics/skills: 16 (docs, design, MCP, testing)
- obra/superpowers: 14 (TDD, debugging, agents, planning)
- coreyhaines31/marketingskills: 25 (marketing, CRO, SEO, growth)
- better-auth/skills: 5 (auth patterns)
- vercel-labs/agent-skills: 5 (React, design, Vercel)
- antfu/skills: 16 (Vue, Vite, Vitest, pnpm, Turborepo)
- Plus 13 individual skills from various repos

Mosaic Stack is not limited to coding — the Orchestrator and
subagents serve coding, business, design, marketing, writing,
logistics, analysis, and more.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 16:27:42 -06:00

72 KiB
Raw Permalink Blame History

React Native Skills

Version 1.0.0
Engineering
January 2026

Note:
This document is mainly for agents and LLMs to follow when maintaining,
generating, or refactoring React Native codebases. Humans
may also find it useful, but guidance here is optimized for automation
and consistency by AI-assisted workflows.


Abstract

Comprehensive performance optimization guide for React Native applications, designed for AI agents and LLMs. Contains 35+ rules across 13 categories, prioritized by impact from critical (core rendering, list performance) to incremental (fonts, imports). Each rule includes detailed explanations, real-world examples comparing incorrect vs. correct implementations, and specific impact metrics to guide automated refactoring and code generation.


Table of Contents

  1. Core RenderingCRITICAL
  2. List PerformanceHIGH
  3. AnimationHIGH
  4. Scroll PerformanceHIGH
  5. NavigationHIGH
  6. React StateMEDIUM
  7. State ArchitectureMEDIUM
  8. React CompilerMEDIUM
  9. User InterfaceMEDIUM
  10. Design SystemMEDIUM
  1. MonorepoLOW
  1. Third-Party DependenciesLOW
  1. JavaScriptLOW
  1. FontsLOW

1. Core Rendering

Impact: CRITICAL

Fundamental React Native rendering rules. Violations cause runtime crashes or broken UI.

1.1 Never Use && with Potentially Falsy Values

Impact: CRITICAL (prevents production crash)

Never use {value && <Component />} when value could be an empty string or

0. These are falsy but JSX-renderable—React Native will try to render them as

text outside a <Text> component, causing a hard crash in production.

Incorrect: crashes if count is 0 or name is ""

function Profile({ name, count }: { name: string; count: number }) {
  return (
    <View>
      {name && <Text>{name}</Text>}
      {count && <Text>{count} items</Text>}
    </View>
  )
}
// If name="" or count=0, renders the falsy value → crash

Correct: ternary with null

function Profile({ name, count }: { name: string; count: number }) {
  return (
    <View>
      {name ? <Text>{name}</Text> : null}
      {count ? <Text>{count} items</Text> : null}
    </View>
  )
}

Correct: explicit boolean coercion

function Profile({ name, count }: { name: string; count: number }) {
  return (
    <View>
      {!!name && <Text>{name}</Text>}
      {!!count && <Text>{count} items</Text>}
    </View>
  )
}

Best: early return

function Profile({ name, count }: { name: string; count: number }) {
  if (!name) return null

  return (
    <View>
      <Text>{name}</Text>
      {count > 0 ? <Text>{count} items</Text> : null}
    </View>
  )
}

Early returns are clearest. When using conditionals inline, prefer ternary or

explicit boolean checks.

Lint rule: Enable react/jsx-no-leaked-render from

eslint-plugin-react

to catch this automatically.

1.2 Wrap Strings in Text Components

Impact: CRITICAL (prevents runtime crash)

Strings must be rendered inside <Text>. React Native crashes if a string is a

direct child of <View>.

Incorrect: crashes

import { View } from 'react-native'

function Greeting({ name }: { name: string }) {
  return <View>Hello, {name}!</View>
}
// Error: Text strings must be rendered within a <Text> component.

Correct:

import { View, Text } from 'react-native'

function Greeting({ name }: { name: string }) {
  return (
    <View>
      <Text>Hello, {name}!</Text>
    </View>
  )
}

2. List Performance

Impact: HIGH

Optimizing virtualized lists (FlatList, LegendList, FlashList) for smooth scrolling and fast updates.

2.1 Avoid Inline Objects in renderItem

Impact: HIGH (prevents unnecessary re-renders of memoized list items)

Don't create new objects inside renderItem to pass as props. Inline objects

create new references on every render, breaking memoization. Pass primitive

values directly from item instead.

Incorrect: inline object breaks memoization

function UserList({ users }: { users: User[] }) {
  return (
    <LegendList
      data={users}
      renderItem={({ item }) => (
        <UserRow
          // Bad: new object on every render
          user={{ id: item.id, name: item.name, avatar: item.avatar }}
        />
      )}
    />
  )
}

Incorrect: inline style object

renderItem={({ item }) => (
  <UserRow
    name={item.name}
    // Bad: new style object on every render
    style={{ backgroundColor: item.isActive ? 'green' : 'gray' }}
  />
)}

Correct: pass item directly or primitives

function UserList({ users }: { users: User[] }) {
  return (
    <LegendList
      data={users}
      renderItem={({ item }) => (
        // Good: pass the item directly
        <UserRow user={item} />
      )}
    />
  )
}

Correct: pass primitives, derive inside child

renderItem={({ item }) => (
  <UserRow
    id={item.id}
    name={item.name}
    isActive={item.isActive}
  />
)}

const UserRow = memo(function UserRow({ id, name, isActive }: Props) {
  // Good: derive style inside memoized component
  const backgroundColor = isActive ? 'green' : 'gray'
  return <View style={[styles.row, { backgroundColor }]}>{/* ... */}</View>
})

Correct: hoist static styles in module scope

const activeStyle = { backgroundColor: 'green' }
const inactiveStyle = { backgroundColor: 'gray' }

renderItem={({ item }) => (
  <UserRow
    name={item.name}
    // Good: stable references
    style={item.isActive ? activeStyle : inactiveStyle}
  />
)}

Passing primitives or stable references allows memo() to skip re-renders when

the actual values haven't changed.

Note: If you have the React Compiler enabled, it handles memoization

automatically and these manual optimizations become less critical.

2.2 Hoist callbacks to the root of lists

Impact: MEDIUM (Fewer re-renders and faster lists)

When passing callback functions to list items, create a single instance of the

callback at the root of the list. Items should then call it with a unique

identifier.

Incorrect: creates a new callback on each render

return (
  <LegendList
    renderItem={({ item }) => {
      // bad: creates a new callback on each render
      const onPress = () => handlePress(item.id)
      return <Item key={item.id} item={item} onPress={onPress} />
    }}
  />
)

Correct: a single function instance passed to each item

const onPress = useCallback(() => handlePress(item.id), [handlePress, item.id])

return (
  <LegendList
    renderItem={({ item }) => (
      <Item key={item.id} item={item} onPress={onPress} />
    )}
  />
)

Reference: https://example.com

2.3 Keep List Items Lightweight

Impact: HIGH (reduces render time for visible items during scroll)

List items should be as inexpensive as possible to render. Minimize hooks, avoid

queries, and limit React Context access. Virtualized lists render many items

during scroll—expensive items cause jank.

Incorrect: heavy list item

function ProductRow({ id }: { id: string }) {
  // Bad: query inside list item
  const { data: product } = useQuery(['product', id], () => fetchProduct(id))
  // Bad: multiple context accesses
  const theme = useContext(ThemeContext)
  const user = useContext(UserContext)
  const cart = useContext(CartContext)
  // Bad: expensive computation
  const recommendations = useMemo(
    () => computeRecommendations(product),
    [product]
  )

  return <View>{/* ... */}</View>
}

Correct: lightweight list item

function ProductRow({ name, price, imageUrl }: Props) {
  // Good: receives only primitives, minimal hooks
  return (
    <View>
      <Image source={{ uri: imageUrl }} />
      <Text>{name}</Text>
      <Text>{price}</Text>
    </View>
  )
}

Move data fetching to parent:

// Parent fetches all data once
function ProductList() {
  const { data: products } = useQuery(['products'], fetchProducts)

  return (
    <LegendList
      data={products}
      renderItem={({ item }) => (
        <ProductRow name={item.name} price={item.price} imageUrl={item.image} />
      )}
    />
  )
}

For shared values, use Zustand selectors instead of Context:

// Incorrect: Context causes re-render when any cart value changes
function ProductRow({ id, name }: Props) {
  const { items } = useContext(CartContext)
  const inCart = items.includes(id)
  // ...
}

// Correct: Zustand selector only re-renders when this specific value changes
function ProductRow({ id, name }: Props) {
  // use Set.has (created once at the root) instead of Array.includes()
  const inCart = useCartStore((s) => s.items.has(id))
  // ...
}

Guidelines for list items:

  • No queries or data fetching

  • No expensive computations (move to parent or memoize at parent level)

  • Prefer Zustand selectors over React Context

  • Minimize useState/useEffect hooks

  • Pass pre-computed values as props

The goal: list items should be simple rendering functions that take props and

return JSX.

2.4 Optimize List Performance with Stable Object References

Impact: CRITICAL (virtualization relies on reference stability)

Don't map or filter data before passing to virtualized lists. Virtualization

relies on object reference stability to know what changed—new references cause

full re-renders of all visible items. Attempt to prevent frequent renders at the

list-parent level.

Where needed, use context selectors within list items.

Incorrect: creates new object references on every keystroke

function DomainSearch() {
  const { keyword, setKeyword } = useKeywordZustandState()
  const { data: tlds } = useTlds()

  // Bad: creates new objects on every render, reparenting the entire list on every keystroke
  const domains = tlds.map((tld) => ({
    domain: `${keyword}.${tld.name}`,
    tld: tld.name,
    price: tld.price,
  }))

  return (
    <>
      <TextInput value={keyword} onChangeText={setKeyword} />
      <LegendList
        data={domains}
        renderItem={({ item }) => <DomainItem item={item} keyword={keyword} />}
      />
    </>
  )
}

Correct: stable references, transform inside items

const renderItem = ({ item }) => <DomainItem tld={item} />

function DomainSearch() {
  const { data: tlds } = useTlds()

  return (
    <LegendList
      // good: as long as the data is stable, LegendList will not re-render the entire list
      data={tlds}
      renderItem={renderItem}
    />
  )
}

function DomainItem({ tld }: { tld: Tld }) {
  // good: transform within items, and don't pass the dynamic data as a prop
  // good: use a selector function from zustand to receive a stable string back
  const domain = useKeywordZustandState((s) => s.keyword + '.' + tld.name)
  return <Text>{domain}</Text>
}

Updating parent array reference:

// good: creates a new array instance without mutating the inner objects
// good: parent array reference is unaffected by typing and updating "keyword"
const sortedTlds = tlds.toSorted((a, b) => a.name.localeCompare(b.name))

return <LegendList data={sortedTlds} renderItem={renderItem} />

Creating a new array instance can be okay, as long as its inner object

references are stable. For instance, if you sort a list of objects:

Even though this creates a new array instance sortedTlds, the inner object

references are stable.

With zustand for dynamic data: avoids parent re-renders

function DomainItemFavoriteButton({ tld }: { tld: Tld }) {
  const isFavorited = useFavoritesStore((s) => s.favorites.has(tld.id))
  return <TldFavoriteButton isFavorited={isFavorited} />
}

Virtualization can now skip items that haven't changed when typing. Only visible

items (~20) re-render on keystroke, rather than the parent.

**Deriving state within list items based on parent data (avoids parent

re-renders):**

For components where the data is conditional based on the parent state, this

pattern is even more important. For example, if you are checking if an item is

favorited, toggling favorites only re-renders one component if the item itself

is in charge of accessing the state rather than the parent:

Note: if you're using the React Compiler, you can read React Context values

directly within list items. Although this is slightly slower than using a

Zustand selector in most cases, the effect may be negligible.

2.5 Pass Primitives to List Items for Memoization

Impact: HIGH (enables effective memo() comparison)

When possible, pass only primitive values (strings, numbers, booleans) as props

to list item components. Primitives enable shallow comparison in memo() to

work correctly, skipping re-renders when values haven't changed.

Incorrect: object prop requires deep comparison

type User = { id: string; name: string; email: string; avatar: string }

const UserRow = memo(function UserRow({ user }: { user: User }) {
  // memo() compares user by reference, not value
  // If parent creates new user object, this re-renders even if data is same
  return <Text>{user.name}</Text>
})

renderItem={({ item }) => <UserRow user={item} />}

This can still be optimized, but it is harder to memoize properly.

Correct: primitive props enable shallow comparison

const UserRow = memo(function UserRow({
  id,
  name,
  email,
}: {
  id: string
  name: string
  email: string
}) {
  // memo() compares each primitive directly
  // Re-renders only if id, name, or email actually changed
  return <Text>{name}</Text>
})

renderItem={({ item }) => (
  <UserRow id={item.id} name={item.name} email={item.email} />
)}

Pass only what you need:

// Incorrect: passing entire item when you only need name
<UserRow user={item} />

// Correct: pass only the fields the component uses
<UserRow name={item.name} avatarUrl={item.avatar} />

For callbacks, hoist or use item ID:

// Incorrect: inline function creates new reference
<UserRow name={item.name} onPress={() => handlePress(item.id)} />

// Correct: pass ID, handle in child
<UserRow id={item.id} name={item.name} />

const UserRow = memo(function UserRow({ id, name }: Props) {
  const handlePress = useCallback(() => {
    // use id here
  }, [id])
  return <Pressable onPress={handlePress}><Text>{name}</Text></Pressable>
})

Primitive props make memoization predictable and effective.

Note: If you have the React Compiler enabled, you do not need to use

memo() or useCallback(), but the object references still apply.

2.6 Use a List Virtualizer for Any List

Impact: HIGH (reduced memory, faster mounts)

Use a list virtualizer like LegendList or FlashList instead of ScrollView with

mapped children—even for short lists. Virtualizers only render visible items,

reducing memory usage and mount time. ScrollView renders all children upfront,

which gets expensive quickly.

Incorrect: ScrollView renders all items at once

function Feed({ items }: { items: Item[] }) {
  return (
    <ScrollView>
      {items.map((item) => (
        <ItemCard key={item.id} item={item} />
      ))}
    </ScrollView>
  )
}
// 50 items = 50 components mounted, even if only 10 visible

Correct: virtualizer renders only visible items

import { LegendList } from '@legendapp/list'

function Feed({ items }: { items: Item[] }) {
  return (
    <LegendList
      data={items}
      // if you aren't using React Compiler, wrap these with useCallback
      renderItem={({ item }) => <ItemCard item={item} />}
      keyExtractor={(item) => item.id}
      estimatedItemSize={80}
    />
  )
}
// Only ~10-15 visible items mounted at a time

Alternative: FlashList

import { FlashList } from '@shopify/flash-list'

function Feed({ items }: { items: Item[] }) {
  return (
    <FlashList
      data={items}
      // if you aren't using React Compiler, wrap these with useCallback
      renderItem={({ item }) => <ItemCard item={item} />}
      keyExtractor={(item) => item.id}
    />
  )
}

Benefits apply to any screen with scrollable content—profiles, settings, feeds,

search results. Default to virtualization.

2.7 Use Compressed Images in Lists

Impact: HIGH (faster load times, less memory)

Always load compressed, appropriately-sized images in lists. Full-resolution

images consume excessive memory and cause scroll jank. Request thumbnails from

your server or use an image CDN with resize parameters.

Incorrect: full-resolution images

function ProductItem({ product }: { product: Product }) {
  return (
    <View>
      {/* 4000x3000 image loaded for a 100x100 thumbnail */}
      <Image
        source={{ uri: product.imageUrl }}
        style={{ width: 100, height: 100 }}
      />
      <Text>{product.name}</Text>
    </View>
  )
}

Correct: request appropriately-sized image

function ProductItem({ product }: { product: Product }) {
  // Request a 200x200 image (2x for retina)
  const thumbnailUrl = `${product.imageUrl}?w=200&h=200&fit=cover`

  return (
    <View>
      <Image
        source={{ uri: thumbnailUrl }}
        style={{ width: 100, height: 100 }}
        contentFit='cover'
      />
      <Text>{product.name}</Text>
    </View>
  )
}

Use an optimized image component with built-in caching and placeholder support,

such as expo-image or SolitoImage (which uses expo-image under the hood).

Request images at 2x the display size for retina screens.

2.8 Use Item Types for Heterogeneous Lists

Impact: HIGH (efficient recycling, less layout thrashing)

When a list has different item layouts (messages, images, headers, etc.), use a

type field on each item and provide getItemType to the list. This puts items

into separate recycling pools so a message component never gets recycled into an

image component.

LegendList getItemType

Incorrect: single component with conditionals

type Item = { id: string; text?: string; imageUrl?: string; isHeader?: boolean }

function ListItem({ item }: { item: Item }) {
  if (item.isHeader) {
    return <HeaderItem title={item.text} />
  }
  if (item.imageUrl) {
    return <ImageItem url={item.imageUrl} />
  }
  return <MessageItem text={item.text} />
}

function Feed({ items }: { items: Item[] }) {
  return (
    <LegendList
      data={items}
      renderItem={({ item }) => <ListItem item={item} />}
      recycleItems
    />
  )
}

Correct: typed items with separate components

type HeaderItem = { id: string; type: 'header'; title: string }
type MessageItem = { id: string; type: 'message'; text: string }
type ImageItem = { id: string; type: 'image'; url: string }
type FeedItem = HeaderItem | MessageItem | ImageItem

function Feed({ items }: { items: FeedItem[] }) {
  return (
    <LegendList
      data={items}
      keyExtractor={(item) => item.id}
      getItemType={(item) => item.type}
      renderItem={({ item }) => {
        switch (item.type) {
          case 'header':
            return <SectionHeader title={item.title} />
          case 'message':
            return <MessageRow text={item.text} />
          case 'image':
            return <ImageRow url={item.url} />
        }
      }}
      recycleItems
    />
  )
}

Why this matters:

<LegendList
  data={items}
  keyExtractor={(item) => item.id}
  getItemType={(item) => item.type}
  getEstimatedItemSize={(index, item, itemType) => {
    switch (itemType) {
      case 'header':
        return 48
      case 'message':
        return 72
      case 'image':
        return 300
      default:
        return 72
    }
  }}
  renderItem={({ item }) => {
    /* ... */
  }}
  recycleItems
/>
  • Recycling efficiency: Items with the same type share a recycling pool

  • No layout thrashing: A header never recycles into an image cell

  • Type safety: TypeScript can narrow the item type in each branch

  • Better size estimation: Use getEstimatedItemSize with itemType for

    accurate estimates per type


3. Animation

Impact: HIGH

GPU-accelerated animations, Reanimated patterns, and avoiding render thrashing during gestures.

3.1 Animate Transform and Opacity Instead of Layout Properties

Impact: HIGH (GPU-accelerated animations, no layout recalculation)

Avoid animating width, height, top, left, margin, or padding. These trigger layout recalculation on every frame. Instead, use transform (scale, translate) and opacity which run on the GPU without triggering layout.

Incorrect: animates height, triggers layout every frame

import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated'

function CollapsiblePanel({ expanded }: { expanded: boolean }) {
  const animatedStyle = useAnimatedStyle(() => ({
    height: withTiming(expanded ? 200 : 0), // triggers layout on every frame
    overflow: 'hidden',
  }))

  return <Animated.View style={animatedStyle}>{children}</Animated.View>
}

Correct: animates scaleY, GPU-accelerated

import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated'

function CollapsiblePanel({ expanded }: { expanded: boolean }) {
  const animatedStyle = useAnimatedStyle(() => ({
    transform: [
      { scaleY: withTiming(expanded ? 1 : 0) },
    ],
    opacity: withTiming(expanded ? 1 : 0),
  }))

  return (
    <Animated.View style={[{ height: 200, transformOrigin: 'top' }, animatedStyle]}>
      {children}
    </Animated.View>
  )
}

Correct: animates translateY for slide animations

import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated'

function SlideIn({ visible }: { visible: boolean }) {
  const animatedStyle = useAnimatedStyle(() => ({
    transform: [
      { translateY: withTiming(visible ? 0 : 100) },
    ],
    opacity: withTiming(visible ? 1 : 0),
  }))

  return <Animated.View style={animatedStyle}>{children}</Animated.View>
}

GPU-accelerated properties: transform (translate, scale, rotate), opacity. Everything else triggers layout.

3.2 Prefer useDerivedValue Over useAnimatedReaction

Impact: MEDIUM (cleaner code, automatic dependency tracking)

When deriving a shared value from another, use useDerivedValue instead of

useAnimatedReaction. Derived values are declarative, automatically track

dependencies, and return a value you can use directly. Animated reactions are

for side effects, not derivations.

Reanimated useDerivedValue

Incorrect: useAnimatedReaction for derivation

import { useSharedValue, useAnimatedReaction } from 'react-native-reanimated'

function MyComponent() {
  const progress = useSharedValue(0)
  const opacity = useSharedValue(1)

  useAnimatedReaction(
    () => progress.value,
    (current) => {
      opacity.value = 1 - current
    }
  )

  // ...
}

Correct: useDerivedValue

import { useSharedValue, useDerivedValue } from 'react-native-reanimated'

function MyComponent() {
  const progress = useSharedValue(0)

  const opacity = useDerivedValue(() => 1 - progress.get())

  // ...
}

Use useAnimatedReaction only for side effects that don't produce a value

(e.g., triggering haptics, logging, calling runOnJS).

3.3 Use GestureDetector for Animated Press States

Impact: MEDIUM (UI thread animations, smoother press feedback)

For animated press states (scale, opacity on press), use GestureDetector with

Gesture.Tap() and shared values instead of Pressable's

onPressIn/onPressOut. Gesture callbacks run on the UI thread as worklets—no

JS thread round-trip for press animations.

Gesture Handler Tap Gesture

Incorrect: Pressable with JS thread callbacks

import { Pressable } from 'react-native'
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withTiming,
} from 'react-native-reanimated'

function AnimatedButton({ onPress }: { onPress: () => void }) {
  const scale = useSharedValue(1)

  const animatedStyle = useAnimatedStyle(() => ({
    transform: [{ scale: scale.value }],
  }))

  return (
    <Pressable
      onPress={onPress}
      onPressIn={() => (scale.value = withTiming(0.95))}
      onPressOut={() => (scale.value = withTiming(1))}
    >
      <Animated.View style={animatedStyle}>
        <Text>Press me</Text>
      </Animated.View>
    </Pressable>
  )
}

Correct: GestureDetector with UI thread worklets

import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import Animated, {
  useSharedValue,
  useAnimatedStyle,
  withTiming,
  interpolate,
  runOnJS,
} from 'react-native-reanimated'

function AnimatedButton({ onPress }: { onPress: () => void }) {
  // Store the press STATE (0 = not pressed, 1 = pressed)
  const pressed = useSharedValue(0)

  const tap = Gesture.Tap()
    .onBegin(() => {
      pressed.set(withTiming(1))
    })
    .onFinalize(() => {
      pressed.set(withTiming(0))
    })
    .onEnd(() => {
      runOnJS(onPress)()
    })

  // Derive visual values from the state
  const animatedStyle = useAnimatedStyle(() => ({
    transform: [
      { scale: interpolate(withTiming(pressed.get()), [0, 1], [1, 0.95]) },
    ],
  }))

  return (
    <GestureDetector gesture={tap}>
      <Animated.View style={animatedStyle}>
        <Text>Press me</Text>
      </Animated.View>
    </GestureDetector>
  )
}

Store the press state (0 or 1), then derive the scale via interpolate.

This keeps the shared value as ground truth. Use runOnJS to call JS functions

from worklets. Use .set() and .get() for React Compiler compatibility.


4. Scroll Performance

Impact: HIGH

Tracking scroll position without causing render thrashing.

4.1 Never Track Scroll Position in useState

Impact: HIGH (prevents render thrashing during scroll)

Never store scroll position in useState. Scroll events fire rapidly—state

updates cause render thrashing and dropped frames. Use a Reanimated shared value

for animations or a ref for non-reactive tracking.

Incorrect: useState causes jank

import { useState } from 'react'
import {
  ScrollView,
  NativeSyntheticEvent,
  NativeScrollEvent,
} from 'react-native'

function Feed() {
  const [scrollY, setScrollY] = useState(0)

  const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
    setScrollY(e.nativeEvent.contentOffset.y) // re-renders on every frame
  }

  return <ScrollView onScroll={onScroll} scrollEventThrottle={16} />
}

Correct: Reanimated for animations

import Animated, {
  useSharedValue,
  useAnimatedScrollHandler,
} from 'react-native-reanimated'

function Feed() {
  const scrollY = useSharedValue(0)

  const onScroll = useAnimatedScrollHandler({
    onScroll: (e) => {
      scrollY.value = e.contentOffset.y // runs on UI thread, no re-render
    },
  })

  return (
    <Animated.ScrollView
      onScroll={onScroll}
      // higher number has better performance, but it fires less often.
      // unset this if you need higher precision over performance.
      scrollEventThrottle={16}
    />
  )
}

Correct: ref for non-reactive tracking

import { useRef } from 'react'
import {
  ScrollView,
  NativeSyntheticEvent,
  NativeScrollEvent,
} from 'react-native'

function Feed() {
  const scrollY = useRef(0)

  const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
    scrollY.current = e.nativeEvent.contentOffset.y // no re-render
  }

  return <ScrollView onScroll={onScroll} scrollEventThrottle={16} />
}

5. Navigation

Impact: HIGH

Using native navigators for stack and tab navigation instead of JS-based alternatives.

5.1 Use Native Navigators for Navigation

Impact: HIGH (native performance, platform-appropriate UI)

Always use native navigators instead of JS-based ones. Native navigators use

platform APIs (UINavigationController on iOS, Fragment on Android) for better

performance and native behavior.

For stacks: Use @react-navigation/native-stack or expo-router's default

stack (which uses native-stack). Avoid @react-navigation/stack.

For tabs: Use react-native-bottom-tabs (native) or expo-router's native

tabs. Avoid @react-navigation/bottom-tabs when native feel matters.

Incorrect: JS stack navigator

import { createStackNavigator } from '@react-navigation/stack'

const Stack = createStackNavigator()

function App() {
  return (
    <Stack.Navigator>
      <Stack.Screen name='Home' component={HomeScreen} />
      <Stack.Screen name='Details' component={DetailsScreen} />
    </Stack.Navigator>
  )
}

Correct: native stack with react-navigation

import { createNativeStackNavigator } from '@react-navigation/native-stack'

const Stack = createNativeStackNavigator()

function App() {
  return (
    <Stack.Navigator>
      <Stack.Screen name='Home' component={HomeScreen} />
      <Stack.Screen name='Details' component={DetailsScreen} />
    </Stack.Navigator>
  )
}

Correct: expo-router uses native stack by default

// app/_layout.tsx
import { Stack } from 'expo-router'

export default function Layout() {
  return <Stack />
}

Incorrect: JS bottom tabs

import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'

const Tab = createBottomTabNavigator()

function App() {
  return (
    <Tab.Navigator>
      <Tab.Screen name='Home' component={HomeScreen} />
      <Tab.Screen name='Settings' component={SettingsScreen} />
    </Tab.Navigator>
  )
}

Correct: native bottom tabs with react-navigation

import { createNativeBottomTabNavigator } from '@bottom-tabs/react-navigation'

const Tab = createNativeBottomTabNavigator()

function App() {
  return (
    <Tab.Navigator>
      <Tab.Screen
        name='Home'
        component={HomeScreen}
        options={{
          tabBarIcon: () => ({ sfSymbol: 'house' }),
        }}
      />
      <Tab.Screen
        name='Settings'
        component={SettingsScreen}
        options={{
          tabBarIcon: () => ({ sfSymbol: 'gear' }),
        }}
      />
    </Tab.Navigator>
  )
}

Correct: expo-router native tabs

// app/(tabs)/_layout.tsx
import { NativeTabs } from 'expo-router/unstable-native-tabs'

export default function TabLayout() {
  return (
    <NativeTabs>
      <NativeTabs.Trigger name='index'>
        <NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
        <NativeTabs.Trigger.Icon sf='house.fill' md='home' />
      </NativeTabs.Trigger>
      <NativeTabs.Trigger name='settings'>
        <NativeTabs.Trigger.Label>Settings</NativeTabs.Trigger.Label>
        <NativeTabs.Trigger.Icon sf='gear' md='settings' />
      </NativeTabs.Trigger>
    </NativeTabs>
  )
}

On iOS, native tabs automatically enable contentInsetAdjustmentBehavior on the

first ScrollView at the root of each tab screen, so content scrolls correctly

behind the translucent tab bar. If you need to disable this, use

disableAutomaticContentInsets on the trigger.

Incorrect: custom header component

<Stack.Screen
  name='Profile'
  component={ProfileScreen}
  options={{
    header: () => <CustomHeader title='Profile' />,
  }}
/>

Correct: native header options

<Stack.Screen
  name='Profile'
  component={ProfileScreen}
  options={{
    title: 'Profile',
    headerLargeTitleEnabled: true,
    headerSearchBarOptions: {
      placeholder: 'Search',
    },
  }}
/>

Native headers support iOS large titles, search bars, blur effects, and proper

safe area handling automatically.

  • Performance: Native transitions and gestures run on the UI thread

  • Platform behavior: Automatic iOS large titles, Android material design

  • System integration: Scroll-to-top on tab tap, PiP avoidance, proper safe

    areas

  • Accessibility: Platform accessibility features work automatically


6. React State

Impact: MEDIUM

Patterns for managing React state to avoid stale closures and unnecessary re-renders.

6.1 Minimize State Variables and Derive Values

Impact: MEDIUM (fewer re-renders, less state drift)

Use the fewest state variables possible. If a value can be computed from existing state or props, derive it during render instead of storing it in state. Redundant state causes unnecessary re-renders and can drift out of sync.

Incorrect: redundant state

function Cart({ items }: { items: Item[] }) {
  const [total, setTotal] = useState(0)
  const [itemCount, setItemCount] = useState(0)

  useEffect(() => {
    setTotal(items.reduce((sum, item) => sum + item.price, 0))
    setItemCount(items.length)
  }, [items])

  return (
    <View>
      <Text>{itemCount} items</Text>
      <Text>Total: ${total}</Text>
    </View>
  )
}

Correct: derived values

function Cart({ items }: { items: Item[] }) {
  const total = items.reduce((sum, item) => sum + item.price, 0)
  const itemCount = items.length

  return (
    <View>
      <Text>{itemCount} items</Text>
      <Text>Total: ${total}</Text>
    </View>
  )
}

Another example:

// Incorrect: storing both firstName, lastName, AND fullName
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [fullName, setFullName] = useState('')

// Correct: derive fullName
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const fullName = `${firstName} ${lastName}`

State should be the minimal source of truth. Everything else is derived.

Reference: https://react.dev/learn/choosing-the-state-structure

6.2 Use fallback state instead of initialState

Impact: MEDIUM (reactive fallbacks without syncing)

Use undefined as initial state and nullish coalescing (??) to fall back to

parent or server values. State represents user intent only—undefined means

"user hasn't chosen yet." This enables reactive fallbacks that update when the

source changes, not just on initial render.

Incorrect: syncs state, loses reactivity

type Props = { fallbackEnabled: boolean }

function Toggle({ fallbackEnabled }: Props) {
  const [enabled, setEnabled] = useState(defaultEnabled)
  // If fallbackEnabled changes, state is stale
  // State mixes user intent with default value

  return <Switch value={enabled} onValueChange={setEnabled} />
}

Correct: state is user intent, reactive fallback

type Props = { fallbackEnabled: boolean }

function Toggle({ fallbackEnabled }: Props) {
  const [_enabled, setEnabled] = useState<boolean | undefined>(undefined)
  const enabled = _enabled ?? defaultEnabled
  // undefined = user hasn't touched it, falls back to prop
  // If defaultEnabled changes, component reflects it
  // Once user interacts, their choice persists

  return <Switch value={enabled} onValueChange={setEnabled} />
}

With server data:

function ProfileForm({ data }: { data: User }) {
  const [_theme, setTheme] = useState<string | undefined>(undefined)
  const theme = _theme ?? data.theme
  // Shows server value until user overrides
  // Server refetch updates the fallback automatically

  return <ThemePicker value={theme} onChange={setTheme} />
}

6.3 useState Dispatch updaters for State That Depends on Current Value

Impact: MEDIUM (avoids stale closures, prevents unnecessary re-renders)

When the next state depends on the current state, use a dispatch updater

(setState(prev => ...)) instead of reading the state variable directly in a

callback. This avoids stale closures and ensures you're comparing against the

latest value.

Incorrect: reads state directly

const [size, setSize] = useState<Size | undefined>(undefined)

const onLayout = (e: LayoutChangeEvent) => {
  const { width, height } = e.nativeEvent.layout
  // size may be stale in this closure
  if (size?.width !== width || size?.height !== height) {
    setSize({ width, height })
  }
}

Correct: dispatch updater

const [size, setSize] = useState<Size | undefined>(undefined)

const onLayout = (e: LayoutChangeEvent) => {
  const { width, height } = e.nativeEvent.layout
  setSize((prev) => {
    if (prev?.width === width && prev?.height === height) return prev
    return { width, height }
  })
}

Returning the previous value from the updater skips the re-render.

For primitive states, you don't need to compare values before firing a

re-render.

Incorrect: unnecessary comparison for primitive state

const [size, setSize] = useState<Size | undefined>(undefined)

const onLayout = (e: LayoutChangeEvent) => {
  const { width, height } = e.nativeEvent.layout
  setSize((prev) => (prev === width ? prev : width))
}

Correct: sets primitive state directly

const [size, setSize] = useState<Size | undefined>(undefined)

const onLayout = (e: LayoutChangeEvent) => {
  const { width, height } = e.nativeEvent.layout
  setSize(width)
}

However, if the next state depends on the current state, you should still use a

dispatch updater.

Incorrect: reads state directly from the callback

const [count, setCount] = useState(0)

const onTap = () => {
  setCount(count + 1)
}

Correct: dispatch updater

const [count, setCount] = useState(0)

const onTap = () => {
  setCount((prev) => prev + 1)
}

7. State Architecture

Impact: MEDIUM

Ground truth principles for state variables and derived values.

7.1 State Must Represent Ground Truth

Impact: HIGH (cleaner logic, easier debugging, single source of truth)

State variables—both React useState and Reanimated shared values—should

represent the actual state of something (e.g., pressed, progress, isOpen),

not derived visual values (e.g., scale, opacity, translateY). Derive

visual values from state using computation or interpolation.

Incorrect: storing the visual output

const scale = useSharedValue(1)

const tap = Gesture.Tap()
  .onBegin(() => {
    scale.set(withTiming(0.95))
  })
  .onFinalize(() => {
    scale.set(withTiming(1))
  })

const animatedStyle = useAnimatedStyle(() => ({
  transform: [{ scale: scale.get() }],
}))

Correct: storing the state, deriving the visual

const pressed = useSharedValue(0) // 0 = not pressed, 1 = pressed

const tap = Gesture.Tap()
  .onBegin(() => {
    pressed.set(withTiming(1))
  })
  .onFinalize(() => {
    pressed.set(withTiming(0))
  })

const animatedStyle = useAnimatedStyle(() => ({
  transform: [{ scale: interpolate(pressed.get(), [0, 1], [1, 0.95]) }],
}))

Why this matters:

State variables should represent real "state", not necessarily a desired end

result.

  1. Single source of truth — The state (pressed) describes what's

    happening; visuals are derived

  2. Easier to extend — Adding opacity, rotation, or other effects just

    requires more interpolations from the same state

  3. Debugging — Inspecting pressed = 1 is clearer than scale = 0.95

  4. Reusable logic — The same pressed value can drive multiple visual

    properties

Same principle for React state:

// Incorrect: storing derived values
const [isExpanded, setIsExpanded] = useState(false)
const [height, setHeight] = useState(0)

useEffect(() => {
  setHeight(isExpanded ? 200 : 0)
}, [isExpanded])

// Correct: derive from state
const [isExpanded, setIsExpanded] = useState(false)
const height = isExpanded ? 200 : 0

State is the minimal truth. Everything else is derived.


8. React Compiler

Impact: MEDIUM

Compatibility patterns for React Compiler with React Native and Reanimated.

8.1 Destructure Functions Early in Render (React Compiler)

Impact: HIGH (stable references, fewer re-renders)

This rule is only applicable if you are using the React Compiler.

Destructure functions from hooks at the top of render scope. Never dot into

objects to call functions. Destructured functions are stable references; dotting

creates new references and breaks memoization.

Incorrect: dotting into object

import { useRouter } from 'expo-router'

function SaveButton(props) {
  const router = useRouter()

  // bad: react-compiler will key the cache on "props" and "router", which are objects that change each render
  const handlePress = () => {
    props.onSave()
    router.push('/success') // unstable reference
  }

  return <Button onPress={handlePress}>Save</Button>
}

Correct: destructure early

import { useRouter } from 'expo-router'

function SaveButton({ onSave }) {
  const { push } = useRouter()

  // good: react-compiler will key on push and onSave
  const handlePress = () => {
    onSave()
    push('/success') // stable reference
  }

  return <Button onPress={handlePress}>Save</Button>
}

8.2 Use .get() and .set() for Reanimated Shared Values (not .value)

Impact: LOW (required for React Compiler compatibility)

With React Compiler enabled, use .get() and .set() instead of reading or

writing .value directly on Reanimated shared values. The compiler can't track

property access—explicit methods ensure correct behavior.

Incorrect: breaks with React Compiler

import { useSharedValue } from 'react-native-reanimated'

function Counter() {
  const count = useSharedValue(0)

  const increment = () => {
    count.value = count.value + 1 // opts out of react compiler
  }

  return <Button onPress={increment} title={`Count: ${count.value}`} />
}

Correct: React Compiler compatible

import { useSharedValue } from 'react-native-reanimated'

function Counter() {
  const count = useSharedValue(0)

  const increment = () => {
    count.set(count.get() + 1)
  }

  return <Button onPress={increment} title={`Count: ${count.get()}`} />
}

See the

Reanimated docs

for more.


9. User Interface

Impact: MEDIUM

Native UI patterns for images, menus, modals, styling, and platform-consistent interfaces.

9.1 Measuring View Dimensions

Impact: MEDIUM (synchronous measurement, avoid unnecessary re-renders)

Use both useLayoutEffect (synchronous) and onLayout (for updates). The sync

measurement gives you the initial size immediately; onLayout keeps it current

when the view changes. For non-primitive states, use a dispatch updater to

compare values and avoid unnecessary re-renders.

Height only:

import { useLayoutEffect, useRef, useState } from 'react'
import { View, LayoutChangeEvent } from 'react-native'

function MeasuredBox({ children }: { children: React.ReactNode }) {
  const ref = useRef<View>(null)
  const [height, setHeight] = useState<number | undefined>(undefined)

  useLayoutEffect(() => {
    // Sync measurement on mount (RN 0.82+)
    const rect = ref.current?.getBoundingClientRect()
    if (rect) setHeight(rect.height)
    // Pre-0.82: ref.current?.measure((x, y, w, h) => setHeight(h))
  }, [])

  const onLayout = (e: LayoutChangeEvent) => {
    setHeight(e.nativeEvent.layout.height)
  }

  return (
    <View ref={ref} onLayout={onLayout}>
      {children}
    </View>
  )
}

Both dimensions:

import { useLayoutEffect, useRef, useState } from 'react'
import { View, LayoutChangeEvent } from 'react-native'

type Size = { width: number; height: number }

function MeasuredBox({ children }: { children: React.ReactNode }) {
  const ref = useRef<View>(null)
  const [size, setSize] = useState<Size | undefined>(undefined)

  useLayoutEffect(() => {
    const rect = ref.current?.getBoundingClientRect()
    if (rect) setSize({ width: rect.width, height: rect.height })
  }, [])

  const onLayout = (e: LayoutChangeEvent) => {
    const { width, height } = e.nativeEvent.layout
    setSize((prev) => {
      // for non-primitive states, compare values before firing a re-render
      if (prev?.width === width && prev?.height === height) return prev
      return { width, height }
    })
  }

  return (
    <View ref={ref} onLayout={onLayout}>
      {children}
    </View>
  )
}

Use functional setState to compare—don't read state directly in the callback.

9.2 Modern React Native Styling Patterns

Impact: MEDIUM (consistent design, smoother borders, cleaner layouts)

Follow these styling patterns for cleaner, more consistent React Native code.

Always use borderCurve: 'continuous' with borderRadius:

Use gap instead of margin for spacing between elements:

// Incorrect  margin on children
<View>
  <Text style={{ marginBottom: 8 }}>Title</Text>
  <Text style={{ marginBottom: 8 }}>Subtitle</Text>
</View>

// Correct  gap on parent
<View style={{ gap: 8 }}>
  <Text>Title</Text>
  <Text>Subtitle</Text>
</View>

Use padding for space within, gap for space between:

<View style={{ padding: 16, gap: 12 }}>
  <Text>First</Text>
  <Text>Second</Text>
</View>

Use experimental_backgroundImage for linear gradients:

// Incorrect  third-party gradient library
<LinearGradient colors={['#000', '#fff']} />

// Correct  native CSS gradient syntax
<View
  style={{
    experimental_backgroundImage: 'linear-gradient(to bottom, #000, #fff)',
  }}
/>

Use CSS boxShadow string syntax for shadows:

// Incorrect  legacy shadow objects or elevation
{ shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1 }
{ elevation: 4 }

// Correct  CSS box-shadow syntax
{ boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)' }

Avoid multiple font sizes use weight and color for emphasis:

// Incorrect  varying font sizes for hierarchy
<Text style={{ fontSize: 18 }}>Title</Text>
<Text style={{ fontSize: 14 }}>Subtitle</Text>
<Text style={{ fontSize: 12 }}>Caption</Text>

// Correct  consistent size, vary weight and color
<Text style={{ fontWeight: '600' }}>Title</Text>
<Text style={{ color: '#666' }}>Subtitle</Text>
<Text style={{ color: '#999' }}>Caption</Text>

Limiting font sizes creates visual consistency. Use fontWeight (bold/semibold)

and grayscale colors for hierarchy instead.

9.3 Use contentInset for Dynamic ScrollView Spacing

Impact: LOW (smoother updates, no layout recalculation)

When adding space to the top or bottom of a ScrollView that may change

(keyboard, toolbars, dynamic content), use contentInset instead of padding.

Changing contentInset doesn't trigger layout recalculation—it adjusts the

scroll area without re-rendering content.

Incorrect: padding causes layout recalculation

function Feed({ bottomOffset }: { bottomOffset: number }) {
  return (
    <ScrollView contentContainerStyle={{ paddingBottom: bottomOffset }}>
      {children}
    </ScrollView>
  )
}
// Changing bottomOffset triggers full layout recalculation

Correct: contentInset for dynamic spacing

function Feed({ bottomOffset }: { bottomOffset: number }) {
  return (
    <ScrollView
      contentInset={{ bottom: bottomOffset }}
      scrollIndicatorInsets={{ bottom: bottomOffset }}
    >
      {children}
    </ScrollView>
  )
}
// Changing bottomOffset only adjusts scroll bounds

Use scrollIndicatorInsets alongside contentInset to keep the scroll

indicator aligned. For static spacing that never changes, padding is fine.

9.4 Use contentInsetAdjustmentBehavior for Safe Areas

Impact: MEDIUM (native safe area handling, no layout shifts)

Use contentInsetAdjustmentBehavior="automatic" on the root ScrollView instead of wrapping content in SafeAreaView or manual padding. This lets iOS handle safe area insets natively with proper scroll behavior.

Incorrect: SafeAreaView wrapper

import { SafeAreaView, ScrollView, View, Text } from 'react-native'

function MyScreen() {
  return (
    <SafeAreaView style={{ flex: 1 }}>
      <ScrollView>
        <View>
          <Text>Content</Text>
        </View>
      </ScrollView>
    </SafeAreaView>
  )
}

Incorrect: manual safe area padding

import { ScrollView, View, Text } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'

function MyScreen() {
  const insets = useSafeAreaInsets()

  return (
    <ScrollView contentContainerStyle={{ paddingTop: insets.top }}>
      <View>
        <Text>Content</Text>
      </View>
    </ScrollView>
  )
}

Correct: native content inset adjustment

import { ScrollView, View, Text } from 'react-native'

function MyScreen() {
  return (
    <ScrollView contentInsetAdjustmentBehavior='automatic'>
      <View>
        <Text>Content</Text>
      </View>
    </ScrollView>
  )
}

The native approach handles dynamic safe areas (keyboard, toolbars) and allows content to scroll behind the status bar naturally.

9.5 Use expo-image for Optimized Images

Impact: HIGH (memory efficiency, caching, blurhash placeholders, progressive loading)

Use expo-image instead of React Native's Image. It provides memory-efficient caching, blurhash placeholders, progressive loading, and better performance for lists.

Incorrect: React Native Image

import { Image } from 'react-native'

function Avatar({ url }: { url: string }) {
  return <Image source={{ uri: url }} style={styles.avatar} />
}

Correct: expo-image

import { Image } from 'expo-image'

function Avatar({ url }: { url: string }) {
  return <Image source={{ uri: url }} style={styles.avatar} />
}

With blurhash placeholder:

<Image
  source={{ uri: url }}
  placeholder={{ blurhash: 'LGF5]+Yk^6#M@-5c,1J5@[or[Q6.' }}
  contentFit="cover"
  transition={200}
  style={styles.image}
/>

With priority and caching:

<Image
  source={{ uri: url }}
  priority="high"
  cachePolicy="memory-disk"
  style={styles.hero}
/>

Key props:

  • placeholder — Blurhash or thumbnail while loading

  • contentFitcover, contain, fill, scale-down

  • transition — Fade-in duration (ms)

  • prioritylow, normal, high

  • cachePolicymemory, disk, memory-disk, none

  • recyclingKey — Unique key for list recycling

For cross-platform (web + native), use SolitoImage from solito/image which uses expo-image under the hood.

Reference: https://docs.expo.dev/versions/latest/sdk/image/

9.6 Use Galeria for Image Galleries and Lightbox

Impact: MEDIUM

For image galleries with lightbox (tap to fullscreen), use @nandorojo/galeria.

It provides native shared element transitions with pinch-to-zoom, double-tap

zoom, and pan-to-close. Works with any image component including expo-image.

Incorrect: custom modal implementation

function ImageGallery({ urls }: { urls: string[] }) {
  const [selected, setSelected] = useState<string | null>(null)

  return (
    <>
      {urls.map((url) => (
        <Pressable key={url} onPress={() => setSelected(url)}>
          <Image source={{ uri: url }} style={styles.thumbnail} />
        </Pressable>
      ))}
      <Modal visible={!!selected} onRequestClose={() => setSelected(null)}>
        <Image source={{ uri: selected! }} style={styles.fullscreen} />
      </Modal>
    </>
  )
}

Correct: Galeria with expo-image

import { Galeria } from '@nandorojo/galeria'
import { Image } from 'expo-image'

function ImageGallery({ urls }: { urls: string[] }) {
  return (
    <Galeria urls={urls}>
      {urls.map((url, index) => (
        <Galeria.Image index={index} key={url}>
          <Image source={{ uri: url }} style={styles.thumbnail} />
        </Galeria.Image>
      ))}
    </Galeria>
  )
}

Single image:

import { Galeria } from '@nandorojo/galeria'
import { Image } from 'expo-image'

function Avatar({ url }: { url: string }) {
  return (
    <Galeria urls={[url]}>
      <Galeria.Image>
        <Image source={{ uri: url }} style={styles.avatar} />
      </Galeria.Image>
    </Galeria>
  )
}

With low-res thumbnails and high-res fullscreen:

<Galeria urls={highResUrls}>
  {lowResUrls.map((url, index) => (
    <Galeria.Image index={index} key={url}>
      <Image source={{ uri: url }} style={styles.thumbnail} />
    </Galeria.Image>
  ))}
</Galeria>

With FlashList:

<Galeria urls={urls}>
  <FlashList
    data={urls}
    renderItem={({ item, index }) => (
      <Galeria.Image index={index}>
        <Image source={{ uri: item }} style={styles.thumbnail} />
      </Galeria.Image>
    )}
    numColumns={3}
    estimatedItemSize={100}
  />
</Galeria>

Works with expo-image, SolitoImage, react-native Image, or any image

component.

Reference: https://github.com/nandorojo/galeria

9.7 Use Native Menus for Dropdowns and Context Menus

Impact: HIGH (native accessibility, platform-consistent UX)

Use native platform menus instead of custom JS implementations. Native menus

provide built-in accessibility, consistent platform UX, and better performance.

Use zeego for cross-platform native menus.

Incorrect: custom JS menu

import { useState } from 'react'
import { View, Pressable, Text } from 'react-native'

function MyMenu() {
  const [open, setOpen] = useState(false)

  return (
    <View>
      <Pressable onPress={() => setOpen(!open)}>
        <Text>Open Menu</Text>
      </Pressable>
      {open && (
        <View style={{ position: 'absolute', top: 40 }}>
          <Pressable onPress={() => console.log('edit')}>
            <Text>Edit</Text>
          </Pressable>
          <Pressable onPress={() => console.log('delete')}>
            <Text>Delete</Text>
          </Pressable>
        </View>
      )}
    </View>
  )
}

Correct: native menu with zeego

import * as DropdownMenu from 'zeego/dropdown-menu'

function MyMenu() {
  return (
    <DropdownMenu.Root>
      <DropdownMenu.Trigger>
        <Pressable>
          <Text>Open Menu</Text>
        </Pressable>
      </DropdownMenu.Trigger>

      <DropdownMenu.Content>
        <DropdownMenu.Item key='edit' onSelect={() => console.log('edit')}>
          <DropdownMenu.ItemTitle>Edit</DropdownMenu.ItemTitle>
        </DropdownMenu.Item>

        <DropdownMenu.Item
          key='delete'
          destructive
          onSelect={() => console.log('delete')}
        >
          <DropdownMenu.ItemTitle>Delete</DropdownMenu.ItemTitle>
        </DropdownMenu.Item>
      </DropdownMenu.Content>
    </DropdownMenu.Root>
  )
}

Context menu: long-press

import * as ContextMenu from 'zeego/context-menu'

function MyContextMenu() {
  return (
    <ContextMenu.Root>
      <ContextMenu.Trigger>
        <View style={{ padding: 20 }}>
          <Text>Long press me</Text>
        </View>
      </ContextMenu.Trigger>

      <ContextMenu.Content>
        <ContextMenu.Item key='copy' onSelect={() => console.log('copy')}>
          <ContextMenu.ItemTitle>Copy</ContextMenu.ItemTitle>
        </ContextMenu.Item>

        <ContextMenu.Item key='paste' onSelect={() => console.log('paste')}>
          <ContextMenu.ItemTitle>Paste</ContextMenu.ItemTitle>
        </ContextMenu.Item>
      </ContextMenu.Content>
    </ContextMenu.Root>
  )
}

Checkbox items:

import * as DropdownMenu from 'zeego/dropdown-menu'

function SettingsMenu() {
  const [notifications, setNotifications] = useState(true)

  return (
    <DropdownMenu.Root>
      <DropdownMenu.Trigger>
        <Pressable>
          <Text>Settings</Text>
        </Pressable>
      </DropdownMenu.Trigger>

      <DropdownMenu.Content>
        <DropdownMenu.CheckboxItem
          key='notifications'
          value={notifications}
          onValueChange={() => setNotifications((prev) => !prev)}
        >
          <DropdownMenu.ItemIndicator />
          <DropdownMenu.ItemTitle>Notifications</DropdownMenu.ItemTitle>
        </DropdownMenu.CheckboxItem>
      </DropdownMenu.Content>
    </DropdownMenu.Root>
  )
}

Submenus:

import * as DropdownMenu from 'zeego/dropdown-menu'

function MenuWithSubmenu() {
  return (
    <DropdownMenu.Root>
      <DropdownMenu.Trigger>
        <Pressable>
          <Text>Options</Text>
        </Pressable>
      </DropdownMenu.Trigger>

      <DropdownMenu.Content>
        <DropdownMenu.Item key='home' onSelect={() => console.log('home')}>
          <DropdownMenu.ItemTitle>Home</DropdownMenu.ItemTitle>
        </DropdownMenu.Item>

        <DropdownMenu.Sub>
          <DropdownMenu.SubTrigger key='more'>
            <DropdownMenu.ItemTitle>More Options</DropdownMenu.ItemTitle>
          </DropdownMenu.SubTrigger>

          <DropdownMenu.SubContent>
            <DropdownMenu.Item key='settings'>
              <DropdownMenu.ItemTitle>Settings</DropdownMenu.ItemTitle>
            </DropdownMenu.Item>

            <DropdownMenu.Item key='help'>
              <DropdownMenu.ItemTitle>Help</DropdownMenu.ItemTitle>
            </DropdownMenu.Item>
          </DropdownMenu.SubContent>
        </DropdownMenu.Sub>
      </DropdownMenu.Content>
    </DropdownMenu.Root>
  )
}

Reference: https://zeego.dev/components/dropdown-menu

9.8 Use Native Modals Over JS-Based Bottom Sheets

Impact: HIGH (native performance, gestures, accessibility)

Use native <Modal> with presentationStyle="formSheet" or React Navigation

v7's native form sheet instead of JS-based bottom sheet libraries. Native modals

have built-in gestures, accessibility, and better performance. Rely on native UI

for low-level primitives.

Incorrect: JS-based bottom sheet

import BottomSheet from 'custom-js-bottom-sheet'

function MyScreen() {
  const sheetRef = useRef<BottomSheet>(null)

  return (
    <View style={{ flex: 1 }}>
      <Button onPress={() => sheetRef.current?.expand()} title='Open' />
      <BottomSheet ref={sheetRef} snapPoints={['50%', '90%']}>
        <View>
          <Text>Sheet content</Text>
        </View>
      </BottomSheet>
    </View>
  )
}

Correct: native Modal with formSheet

import { Modal, View, Text, Button } from 'react-native'

function MyScreen() {
  const [visible, setVisible] = useState(false)

  return (
    <View style={{ flex: 1 }}>
      <Button onPress={() => setVisible(true)} title='Open' />
      <Modal
        visible={visible}
        presentationStyle='formSheet'
        animationType='slide'
        onRequestClose={() => setVisible(false)}
      >
        <View>
          <Text>Sheet content</Text>
        </View>
      </Modal>
    </View>
  )
}

Correct: React Navigation v7 native form sheet

// In your navigator
<Stack.Screen
  name='Details'
  component={DetailsScreen}
  options={{
    presentation: 'formSheet',
    sheetAllowedDetents: 'fitToContents',
  }}
/>

Native modals provide swipe-to-dismiss, proper keyboard avoidance, and

accessibility out of the box.

9.9 Use Pressable Instead of Touchable Components

Impact: LOW (modern API, more flexible)

Never use TouchableOpacity or TouchableHighlight. Use Pressable from

react-native or react-native-gesture-handler instead.

Incorrect: legacy Touchable components

import { TouchableOpacity } from 'react-native'

function MyButton({ onPress }: { onPress: () => void }) {
  return (
    <TouchableOpacity onPress={onPress} activeOpacity={0.7}>
      <Text>Press me</Text>
    </TouchableOpacity>
  )
}

Correct: Pressable

import { Pressable } from 'react-native'

function MyButton({ onPress }: { onPress: () => void }) {
  return (
    <Pressable onPress={onPress}>
      <Text>Press me</Text>
    </Pressable>
  )
}

Correct: Pressable from gesture handler for lists

import { Pressable } from 'react-native-gesture-handler'

function ListItem({ onPress }: { onPress: () => void }) {
  return (
    <Pressable onPress={onPress}>
      <Text>Item</Text>
    </Pressable>
  )
}

Use react-native-gesture-handler Pressable inside scrollable lists for better

gesture coordination, as long as you are using the ScrollView from

react-native-gesture-handler as well.

For animated press states (scale, opacity changes): Use GestureDetector

with Reanimated shared values instead of Pressable's style callback. See the

animation-gesture-detector-press rule.


10. Design System

Impact: MEDIUM

Architecture patterns for building maintainable component libraries.

10.1 Use Compound Components Over Polymorphic Children

Impact: MEDIUM (flexible composition, clearer API)

Don't create components that can accept a string if they aren't a text node. If

a component can receive a string child, it must be a dedicated *Text

component. For components like buttons, which can have both a View (or

Pressable) together with text, use compound components, such a Button,

ButtonText, and ButtonIcon.

Incorrect: polymorphic children

import { Pressable, Text } from 'react-native'

type ButtonProps = {
  children: string | React.ReactNode
  icon?: React.ReactNode
}

function Button({ children, icon }: ButtonProps) {
  return (
    <Pressable>
      {icon}
      {typeof children === 'string' ? <Text>{children}</Text> : children}
    </Pressable>
  )
}

// Usage is ambiguous
<Button icon={<Icon />}>Save</Button>
<Button><CustomText>Save</CustomText></Button>

Correct: compound components

import { Pressable, Text } from 'react-native'

function Button({ children }: { children: React.ReactNode }) {
  return <Pressable>{children}</Pressable>
}

function ButtonText({ children }: { children: React.ReactNode }) {
  return <Text>{children}</Text>
}

function ButtonIcon({ children }: { children: React.ReactNode }) {
  return <>{children}</>
}

// Usage is explicit and composable
<Button>
  <ButtonIcon><SaveIcon /></ButtonIcon>
  <ButtonText>Save</ButtonText>
</Button>

<Button>
  <ButtonText>Cancel</ButtonText>
</Button>

11. Monorepo

Impact: LOW

Dependency management and native module configuration in monorepos.

11.1 Install Native Dependencies in App Directory

Impact: CRITICAL (required for autolinking to work)

In a monorepo, packages with native code must be installed in the native app's

directory directly. Autolinking only scans the app's node_modules—it won't

find native dependencies installed in other packages.

Incorrect: native dep in shared package only

packages/
  ui/
    package.json  # has react-native-reanimated
  app/
    package.json  # missing react-native-reanimated

Autolinking fails—native code not linked.

Correct: native dep in app directory

// packages/app/package.json
{
  "dependencies": {
    "react-native-reanimated": "3.16.1"
  }
}

Even if the shared package uses the native dependency, the app must also list it

for autolinking to detect and link the native code.

11.2 Use Single Dependency Versions Across Monorepo

Impact: MEDIUM (avoids duplicate bundles, version conflicts)

Use a single version of each dependency across all packages in your monorepo.

Prefer exact versions over ranges. Multiple versions cause duplicate code in

bundles, runtime conflicts, and inconsistent behavior across packages.

Use a tool like syncpack to enforce this. As a last resort, use yarn resolutions

or npm overrides.

Incorrect: version ranges, multiple versions

// packages/app/package.json
{
  "dependencies": {
    "react-native-reanimated": "^3.0.0"
  }
}

// packages/ui/package.json
{
  "dependencies": {
    "react-native-reanimated": "^3.5.0"
  }
}

Correct: exact versions, single source of truth

// package.json (root)
{
  "pnpm": {
    "overrides": {
      "react-native-reanimated": "3.16.1"
    }
  }
}

// packages/app/package.json
{
  "dependencies": {
    "react-native-reanimated": "3.16.1"
  }
}

// packages/ui/package.json
{
  "dependencies": {
    "react-native-reanimated": "3.16.1"
  }
}

Use your package manager's override/resolution feature to enforce versions at

the root. When adding dependencies, specify exact versions without ^ or ~.


12. Third-Party Dependencies

Impact: LOW

Wrapping and re-exporting third-party dependencies for maintainability.

12.1 Import from Design System Folder

Impact: LOW (enables global changes and easy refactoring)

Re-export dependencies from a design system folder. App code imports from there,

not directly from packages. This enables global changes and easy refactoring.

Incorrect: imports directly from package

import { View, Text } from 'react-native'
import { Button } from '@ui/button'

function Profile() {
  return (
    <View>
      <Text>Hello</Text>
      <Button>Save</Button>
    </View>
  )
}

Correct: imports from design system

import { View } from '@/components/view'
import { Text } from '@/components/text'
import { Button } from '@/components/button'

function Profile() {
  return (
    <View>
      <Text>Hello</Text>
      <Button>Save</Button>
    </View>
  )
}

Start by simply re-exporting. Customize later without changing app code.


13. JavaScript

Impact: LOW

Micro-optimizations like hoisting expensive object creation.

13.1 Hoist Intl Formatter Creation

Impact: LOW-MEDIUM (avoids expensive object recreation)

Don't create Intl.DateTimeFormat, Intl.NumberFormat, or

Intl.RelativeTimeFormat inside render or loops. These are expensive to

instantiate. Hoist to module scope when the locale/options are static.

Incorrect: new formatter every render

function Price({ amount }: { amount: number }) {
  const formatter = new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD',
  })
  return <Text>{formatter.format(amount)}</Text>
}

Correct: hoisted to module scope

const currencyFormatter = new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD',
})

function Price({ amount }: { amount: number }) {
  return <Text>{currencyFormatter.format(amount)}</Text>
}

For dynamic locales, memoize:

const dateFormatter = useMemo(
  () => new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }),
  [locale]
)

Common formatters to hoist:

// Module-level formatters
const dateFormatter = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' })
const timeFormatter = new Intl.DateTimeFormat('en-US', { timeStyle: 'short' })
const percentFormatter = new Intl.NumberFormat('en-US', { style: 'percent' })
const relativeFormatter = new Intl.RelativeTimeFormat('en-US', {
  numeric: 'auto',
})

Creating Intl objects is significantly more expensive than RegExp or plain

objects—each instantiation parses locale data and builds internal lookup tables.


14. Fonts

Impact: LOW

Native font loading for improved performance.

14.1 Load fonts natively at build time

Impact: LOW (fonts available at launch, no async loading)

Use the expo-font config plugin to embed fonts at build time instead of

useFonts or Font.loadAsync. Embedded fonts are more efficient.

Expo Font Documentation

Incorrect: async font loading

import { useFonts } from 'expo-font'
import { Text, View } from 'react-native'

function App() {
  const [fontsLoaded] = useFonts({
    'Geist-Bold': require('./assets/fonts/Geist-Bold.otf'),
  })

  if (!fontsLoaded) {
    return null
  }

  return (
    <View>
      <Text style={{ fontFamily: 'Geist-Bold' }}>Hello</Text>
    </View>
  )
}

Correct: config plugin, fonts embedded at build

import { Text, View } from 'react-native'

function App() {
  // No loading state needed—font is already available
  return (
    <View>
      <Text style={{ fontFamily: 'Geist-Bold' }}>Hello</Text>
    </View>
  )
}

After adding fonts to the config plugin, run npx expo prebuild and rebuild the

native app.


References

  1. https://react.dev
  2. https://reactnative.dev
  3. https://docs.swmansion.com/react-native-reanimated
  4. https://docs.swmansion.com/react-native-gesture-handler
  5. https://docs.expo.dev
  6. https://legendapp.com/open-source/legend-list
  7. https://github.com/nandorojo/galeria
  8. https://zeego.dev