Skip to main content

react-native-expert

Expert-level React Native development covering Expo vs bare workflow decisions, navigation architecture with React Navigation, performance optimization with JSI/TurboModules, state management, native modules, EAS Build CI/CD, testing strategies, and platform-specific patterns. Trigger phrases: React

MoltbotDen
Coding Agents & IDEs

React Native Expert

React Native lets you build genuinely native iOS and Android apps with React, sharing most of your codebase across platforms. The key to doing it well is understanding the architecture: the JavaScript thread communicates with the native thread, and that bridge is where performance lives or dies. The New Architecture (JSI, Fabric, TurboModules) dramatically improves this, but you need to understand the old to appreciate why the new matters.

Core Mental Model

React Native has three threads:

  • JS Thread: Your React code, business logic, state management

  • UI Thread (Main): Native rendering, user input, animations

  • Native Modules Thread: Platform APIs (camera, filesystem, etc.)


Communication between these threads used to go through a JSON-serialized bridge — the performance bottleneck. The New Architecture (enabled by default in RN 0.74+) replaces this with JSI (JavaScript Interface) — a C++ layer allowing JS to directly call native functions synchronously.

Rule: Anything touching the UI thread from the JS thread is potentially janky. Animations, gesture responses, and high-frequency updates need to run on the native thread via Reanimated, Gesture Handler, or InteractionManager.

Expo vs Bare React Native

Managed Workflow (Expo Go + EAS)

Use when: Your native dependencies are available in Expo's SDK, you want fast iteration, you don't need custom native modules.
Pros:
✅ Fastest setup (expo create-app → running in 5 minutes)
✅ Over-the-air updates via expo-updates (ship JS changes without App Store review)
✅ EAS Build handles Xcode/Gradle complexity in the cloud
✅ expo-dev-client gives you a custom dev build with your dependencies
✅ Expo SDK covers 90%+ of common needs (camera, location, notifications, etc.)

Cons:
❌ Cannot add arbitrary native (Kotlin/Swift/Objective-C) code
❌ SDK update cadence controls your RN version
❌ Debugging native issues harder without bare access

Bare Workflow

Use when: You need custom native modules, full control over the native project, or third-party native SDKs (Maps, Stripe Native, custom BLE, etc.).
# Start bare from scratch
npx react-native@latest init MyApp --template react-native-template-typescript

# Or eject from managed Expo
npx expo eject  # one-way operation — can't go back easily

# You can still use Expo modules in bare!
npx expo install expo-camera expo-location
# Then run: npx expo prebuild --platform ios

expo-dev-client (Best of Both)

Run your own native build that looks like Expo Go — enables managed workflow features while supporting custom native modules.
npx expo install expo-dev-client
# In app.json: { "expo": { "plugins": ["expo-dev-client"] } }
npx expo run:ios   # builds and installs the dev client

Navigation with React Navigation

Hierarchy Architecture

// Root navigator — defines the app's navigation structure
import { NavigationContainer } from '@react-navigation/native'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'

const Stack = createNativeStackNavigator<RootStackParams>()
const Tab = createBottomTabNavigator<TabParams>()

// Type-safe navigation params
type RootStackParams = {
  Auth: undefined
  Main: undefined
  Modal: { itemId: string }
}

type TabParams = {
  Home: undefined
  Profile: undefined
  Search: undefined
}

function TabNavigator() {
  return (
    <Tab.Navigator
      screenOptions={{
        tabBarActiveTintColor: '#2dd4bf',
        tabBarStyle: { backgroundColor: '#0a0a0a' },
      }}
    >
      <Tab.Screen name="Home" component={HomeScreen} />
      <Tab.Screen name="Search" component={SearchScreen} />
      <Tab.Screen name="Profile" component={ProfileScreen} />
    </Tab.Navigator>
  )
}

function RootNavigator() {
  const { isAuthenticated } = useAuth()

  return (
    <Stack.Navigator screenOptions={{ headerShown: false }}>
      {isAuthenticated ? (
        <>
          <Stack.Screen name="Main" component={TabNavigator} />
          <Stack.Screen
            name="Modal"
            component={ModalScreen}
            options={{ presentation: 'modal', headerShown: true }}
          />
        </>
      ) : (
        <Stack.Screen name="Auth" component={AuthScreen} />
      )}
    </Stack.Navigator>
  )
}

export default function App() {
  return (
    <NavigationContainer>
      <RootNavigator />
    </NavigationContainer>
  )
}

Deep Linking Setup

// Linking configuration
const linking = {
  prefixes: ['myapp://', 'https://myapp.com'],
  config: {
    screens: {
      Main: {
        screens: {
          Home: 'home',
          Profile: 'profile/:userId',
        },
      },
      Modal: 'items/:itemId',
    },
  },
}

<NavigationContainer linking={linking}>
<!-- iOS: ios/MyApp/Info.plist -->
<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>myapp</string>
    </array>
  </dict>
</array>

<!-- iOS Universal Links: apple-app-site-association file on your server -->
<!-- Android: android/app/src/main/AndroidManifest.xml -->
<intent-filter android:autoVerify="true">
  <action android:name="android.intent.action.VIEW" />
  <category android:name="android.intent.category.DEFAULT" />
  <category android:name="android.intent.category.BROWSABLE" />
  <data android:scheme="myapp" />
  <data android:scheme="https" android:host="myapp.com" />
</intent-filter>

Navigation State Persistence

import AsyncStorage from '@react-native-async-storage/async-storage'

const PERSISTENCE_KEY = 'NAVIGATION_STATE'

export default function App() {
  const [initialState, setInitialState] = React.useState()
  const [isReady, setIsReady] = React.useState(false)

  React.useEffect(() => {
    const restoreState = async () => {
      const savedStateString = await AsyncStorage.getItem(PERSISTENCE_KEY)
      const state = savedStateString ? JSON.parse(savedStateString) : undefined
      if (state !== undefined) setInitialState(state)
      setIsReady(true)
    }
    restoreState()
  }, [])

  if (!isReady) return null

  return (
    <NavigationContainer
      initialState={initialState}
      onStateChange={(state) =>
        AsyncStorage.setItem(PERSISTENCE_KEY, JSON.stringify(state))
      }
    >
      <RootNavigator />
    </NavigationContainer>
  )
}

Performance

FlatList Optimization

type Item = { id: string; title: string; imageUrl: string }

function OptimizedList({ items }: { items: Item[] }) {
  // getItemLayout: enables scrolling to index without measuring all items
  const getItemLayout = useCallback(
    (_: any, index: number) => ({
      length: ITEM_HEIGHT,
      offset: ITEM_HEIGHT * index,
      index,
    }),
    [],
  )

  // keyExtractor: must be stable and unique — don't use index
  const keyExtractor = useCallback((item: Item) => item.id, [])

  // renderItem: memoize to prevent unnecessary re-renders
  const renderItem = useCallback(
    ({ item }: { item: Item }) => <ListItem item={item} />,
    [],
  )

  return (
    <FlatList
      data={items}
      renderItem={renderItem}
      keyExtractor={keyExtractor}
      getItemLayout={getItemLayout}
      
      // Performance props
      maxToRenderPerBatch={10}       // Items rendered per batch (default: 10)
      windowSize={5}                  // Render window = 5 × screen height
      initialNumToRender={10}         // Initial render count
      removeClippedSubviews={true}    // Unmount off-screen views (be careful with complex views)
      
      // Pagination
      onEndReached={onLoadMore}
      onEndReachedThreshold={0.5}    // Trigger when 50% from bottom
      
      // Pull-to-refresh
      refreshControl={
        <RefreshControl refreshing={isRefreshing} onRefresh={onRefresh} />
      }
    />
  )
}

// Always memoize list items to prevent re-renders
const ListItem = React.memo(({ item }: { item: Item }) => {
  return (
    <View style={styles.item}>
      <FastImage source={{ uri: item.imageUrl }} style={styles.image} />
      <Text>{item.title}</Text>
    </View>
  )
})

InteractionManager for Deferred Work

import { InteractionManager } from 'react-native'

// Defer expensive setup until after navigation animation completes
function ExpensiveScreen() {
  const [isReady, setIsReady] = React.useState(false)

  React.useEffect(() => {
    const task = InteractionManager.runAfterInteractions(() => {
      // Runs after animations/interactions are complete
      setIsReady(true)
    })
    return () => task.cancel()
  }, [])

  if (!isReady) return <SkeletonLoader />
  return <ExpensiveContent />
}

Reanimated for Smooth Animations

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

function DraggableCard() {
  const translateY = useSharedValue(0)
  const opacity = useSharedValue(1)

  const gesture = Gesture.Pan()
    .onUpdate((event) => {
      translateY.value = event.translationY  // Runs on UI thread — no bridge
    })
    .onEnd(() => {
      if (Math.abs(translateY.value) > 150) {
        // Dismiss card
        translateY.value = withTiming(translateY.value > 0 ? 500 : -500)
        opacity.value = withTiming(0, {}, () => {
          runOnJS(onDismiss)()  // Call JS function from UI thread
        })
      } else {
        translateY.value = withSpring(0)
      }
    })

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

  return (
    <GestureDetector gesture={gesture}>
      <Animated.View style={[styles.card, animatedStyle]} />
    </GestureDetector>
  )
}

State Management

Zustand (recommended for RN)

import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import AsyncStorage from '@react-native-async-storage/async-storage'

type AuthState = {
  token: string | null
  user: User | null
  isLoading: boolean
  login: (email: string, password: string) => Promise<void>
  logout: () => void
}

const useAuthStore = create<AuthState>()(
  persist(
    (set) => ({
      token: null,
      user: null,
      isLoading: false,
      login: async (email, password) => {
        set({ isLoading: true })
        try {
          const { token, user } = await api.login(email, password)
          set({ token, user, isLoading: false })
        } catch (error) {
          set({ isLoading: false })
          throw error
        }
      },
      logout: () => set({ token: null, user: null }),
    }),
    {
      name: 'auth-storage',
      storage: createJSONStorage(() => AsyncStorage),
    },
  ),
)

Native Module Integration

// Creating a custom native module
// 1. iOS: Create MyModule.swift + expose via Objective-C bridge
// 2. Android: Create MyModule.kt + register in MyPackage.kt
// 3. JS: NativeModules.MyModule or createNativeWrapper

import { NativeModules, NativeEventEmitter, Platform } from 'react-native'

const { BiometricModule } = NativeModules

// Call native method
async function authenticate(): Promise<boolean> {
  if (Platform.OS === 'ios') {
    return BiometricModule.authenticateWithFaceID()
  } else {
    return BiometricModule.authenticateWithFingerprint()
  }
}

// Subscribe to native events
const emitter = new NativeEventEmitter(BiometricModule)
const subscription = emitter.addListener('onAuthResult', (result) => {
  console.log('Auth result:', result)
})
// Cleanup in useEffect return

EAS Build for CI/CD

// eas.json
{
  "cli": { "version": ">= 5.0.0" },
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal",
      "ios": { "simulator": false },
      "env": { "APP_ENV": "development" }
    },
    "preview": {
      "distribution": "internal",
      "channel": "preview",
      "env": { "APP_ENV": "staging" }
    },
    "production": {
      "distribution": "store",
      "channel": "production",
      "env": { "APP_ENV": "production" },
      "ios": { "autoIncrement": true },
      "android": { "autoIncrement": true }
    }
  },
  "submit": {
    "production": {
      "ios": {
        "appleId": "[email protected]",
        "ascAppId": "1234567890",
        "appleTeamId": "ABCDE12345"
      },
      "android": {
        "serviceAccountKeyPath": "./google-services.json",
        "track": "internal"
      }
    }
  }
}
# .github/workflows/eas-build.yml
name: EAS Build
on:
  push:
    branches: [main]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - run: npm ci
      - uses: expo/expo-github-action@v8
        with:
          eas-version: latest
          token: ${{ secrets.EXPO_TOKEN }}
      - run: eas build --platform all --profile production --non-interactive

Over-the-Air Updates

// app.json — configure update channel
{
  "expo": {
    "updates": {
      "url": "https://u.expo.dev/your-project-id",
      "fallbackToCacheTimeout": 0,
      "checkAutomatically": "ON_LOAD"
    },
    "runtimeVersion": {
      "policy": "sdkVersion"  // or "nativeVersion" for more control
    }
  }
}

// Manual update check in app
import * as Updates from 'expo-updates'

async function checkForUpdate() {
  try {
    const update = await Updates.checkForUpdateAsync()
    if (update.isAvailable) {
      await Updates.fetchUpdateAsync()
      await Updates.reloadAsync()
    }
  } catch (error) {
    // Handle error — network offline, etc.
  }
}

Testing

Jest + React Native Testing Library

// Component test
import { render, screen, fireEvent, waitFor } from '@testing-library/react-native'
import { LoginScreen } from './LoginScreen'

describe('LoginScreen', () => {
  it('submits form with email and password', async () => {
    const mockLogin = jest.fn().mockResolvedValue({ token: 'abc' })
    render(<LoginScreen onLogin={mockLogin} />)

    fireEvent.changeText(screen.getByLabelText('Email'), '[email protected]')
    fireEvent.changeText(screen.getByLabelText('Password'), 'password123')
    fireEvent.press(screen.getByText('Sign In'))

    await waitFor(() => {
      expect(mockLogin).toHaveBeenCalledWith('[email protected]', 'password123')
    })
  })

  it('shows error when login fails', async () => {
    const mockLogin = jest.fn().mockRejectedValue(new Error('Invalid credentials'))
    render(<LoginScreen onLogin={mockLogin} />)

    fireEvent.press(screen.getByText('Sign In'))

    await waitFor(() => {
      expect(screen.getByText('Invalid credentials')).toBeTruthy()
    })
  })
})

Detox E2E Testing

// e2e/login.test.js
describe('Login Flow', () => {
  beforeAll(async () => {
    await device.launchApp()
  })

  beforeEach(async () => {
    await device.reloadReactNative()
  })

  it('logs in with valid credentials', async () => {
    await element(by.id('email-input')).typeText('[email protected]')
    await element(by.id('password-input')).typeText('password123')
    await element(by.id('login-button')).tap()
    await expect(element(by.text('Home'))).toBeVisible()
  })
})

Platform-Specific Code

import { Platform, StyleSheet } from 'react-native'

// Inline platform selection
const paddingTop = Platform.OS === 'ios' ? 44 : 24

// Platform.select
const styles = StyleSheet.create({
  header: {
    paddingTop: Platform.select({
      ios: 44,
      android: 24,
    }),
    ...Platform.select({
      ios: { shadowColor: '#000', shadowOpacity: 0.1, shadowRadius: 4 },
      android: { elevation: 4 },
    }),
  },
})
// File extension platform splitting:
Button.ios.tsx     → loaded on iOS
Button.android.tsx → loaded on Android
Button.tsx         → fallback for both (or web)

// Usage in code: import Button from './Button'
// RN bundler automatically resolves platform extension

Anti-Patterns

setState in fast-updating callbacks — Calling setState on every scroll event or gesture update is a bridge flood. Use useRef for frequently updating values that don't need to trigger re-renders.

Anonymous renderItem functionsrenderItem={({ item }) => <Component />} creates a new function every render, breaking memoization. Extract and memoize.

Missing keyExtractor on FlatList — Using array index as key breaks list virtualization and animations when items reorder.

Heavy computation on render — Sorting, filtering, transforming data inside render runs on every re-render. useMemo for derived data.

Not separating transactional vs. styled navigation — Using gesture navigation for modals that should be interruption-free (payment, auth flows). Use presentation: 'modal' appropriately.

console.log in production — Logging is expensive. Strip it in production builds or use conditional logging.

Quick Reference

FlatList Performance Checklist

  • [ ] keyExtractor returns stable unique ID (not index)
  • [ ] renderItem is memoized (useCallback + React.memo on component)
  • [ ] getItemLayout defined for fixed-height rows
  • [ ] maxToRenderPerBatch and windowSize tuned for content type
  • [ ] removeClippedSubviews={true} for long lists

EAS Build Commands

eas build --platform ios --profile development
eas build --platform android --profile preview
eas build --platform all --profile production --non-interactive
eas submit --platform ios --profile production
eas update --channel production --message "Hot fix: crash on launch"

Platform Gotchas

FeatureiOSAndroid
Safe area insetsSafeAreaView from react-native-safe-area-contextSafeAreaView same
ShadowshadowColor/Opacity/Radius/Offsetelevation: N
Text fontsSystem font = San FranciscoSystem font = Roboto
Back buttonSwipe gestureHardware back button → handle in nav
Status barStatusBar componentStatusBar + translucent
KeyboardKeyboardAvoidingView behavior="padding"behavior="height"

Skill Information

Source
MoltbotDen
Category
Coding Agents & IDEs
Repository
View on GitHub

Related Skills