react-native-architecture

Production-ready React Native patterns with Expo, navigation, offline-first architecture, and native module integration. Covers Expo Router for file-based navigation, authentication flows with secure token storage, and route protection patterns Includes offline-first data sync using React Query with AsyncStorage persistence and online status detection Demonstrates native module integration for haptics, biometrics, push notifications, and platform-specific code patterns Provides performance optimization techniques including FlashList for long lists, component memoization, and Reanimated animations Includes EAS Build and Submit configuration for CI/CD, OTA updates, and app store deployment across iOS and Android

INSTALLATION
npx skills add https://github.com/wshobson/agents --skill react-native-architecture
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

React Native Architecture

Production-ready patterns for React Native development with Expo, including navigation, state management, native modules, and offline-first architecture.

When to Use This Skill

  • Starting a new React Native or Expo project
  • Implementing complex navigation patterns
  • Integrating native modules and platform APIs
  • Building offline-first mobile applications
  • Optimizing React Native performance
  • Setting up CI/CD for mobile releases

Core Concepts

1. Project Structure

src/

├── app/                    # Expo Router screens

│   ├── (auth)/            # Auth group

│   ├── (tabs)/            # Tab navigation

│   └── _layout.tsx        # Root layout

├── components/

│   ├── ui/                # Reusable UI components

│   └── features/          # Feature-specific components

├── hooks/                 # Custom hooks

├── services/              # API and native services

├── stores/                # State management

├── utils/                 # Utilities

└── types/                 # TypeScript types

2. Expo vs Bare React Native

Feature

Expo

Bare RN

Setup complexity

Low

High

Native modules

EAS Build

Manual linking

OTA updates

Built-in

Manual setup

Build service

EAS

Custom CI

Custom native code

Config plugins

Direct access

Quick Start

# Create new Expo project

npx create-expo-app@latest my-app -t expo-template-blank-typescript

# Install essential dependencies

npx expo install expo-router expo-status-bar react-native-safe-area-context

npx expo install @react-native-async-storage/async-storage

npx expo install expo-secure-store expo-haptics
// app/_layout.tsx

import { Stack } from 'expo-router'

import { ThemeProvider } from '@/providers/ThemeProvider'

import { QueryProvider } from '@/providers/QueryProvider'

export default function RootLayout() {

  return (

    <QueryProvider>

      <ThemeProvider>

        <Stack screenOptions={{ headerShown: false }}>

          <Stack.Screen name="(tabs)" />

          <Stack.Screen name="(auth)" />

          <Stack.Screen name="modal" options={{ presentation: 'modal' }} />

        </Stack>

      </ThemeProvider>

    </QueryProvider>

  )

}

Patterns

Pattern 1: Expo Router Navigation

// app/(tabs)/_layout.tsx

import { Tabs } from 'expo-router'

import { Home, Search, User, Settings } from 'lucide-react-native'

import { useTheme } from '@/hooks/useTheme'

export default function TabLayout() {

  const { colors } = useTheme()

  return (

    <Tabs

      screenOptions={{

        tabBarActiveTintColor: colors.primary,

        tabBarInactiveTintColor: colors.textMuted,

        tabBarStyle: { backgroundColor: colors.background },

        headerShown: false,

      }}

    >

      <Tabs.Screen

        name="index"

        options={{

          title: 'Home',

          tabBarIcon: ({ color, size }) => <Home size={size} color={color} />,

        }}

      />

      <Tabs.Screen

        name="search"

        options={{

          title: 'Search',

          tabBarIcon: ({ color, size }) => <Search size={size} color={color} />,

        }}

      />

      <Tabs.Screen

        name="profile"

        options={{

          title: 'Profile',

          tabBarIcon: ({ color, size }) => <User size={size} color={color} />,

        }}

      />

      <Tabs.Screen

        name="settings"

        options={{

          title: 'Settings',

          tabBarIcon: ({ color, size }) => <Settings size={size} color={color} />,

        }}

      />

    </Tabs>

  )

}

// app/(tabs)/profile/[id].tsx - Dynamic route

import { useLocalSearchParams } from 'expo-router'

export default function ProfileScreen() {

  const { id } = useLocalSearchParams<{ id: string }>()

  return <UserProfile userId={id} />

}

// Navigation from anywhere

import { router } from 'expo-router'

// Programmatic navigation

router.push('/profile/123')

router.replace('/login')

router.back()

// With params

router.push({

  pathname: '/product/[id]',

  params: { id: '123', referrer: 'home' },

})

Pattern 2: Authentication Flow

// providers/AuthProvider.tsx

import { createContext, useContext, useEffect, useState } from 'react'

import { useRouter, useSegments } from 'expo-router'

import * as SecureStore from 'expo-secure-store'

interface AuthContextType {

  user: User | null

  isLoading: boolean

  signIn: (credentials: Credentials) => Promise<void>

  signOut: () => Promise<void>

}

const AuthContext = createContext<AuthContextType | null>(null)

export function AuthProvider({ children }: { children: React.ReactNode }) {

  const [user, setUser] = useState<User | null>(null)

  const [isLoading, setIsLoading] = useState(true)

  const segments = useSegments()

  const router = useRouter()

  // Check authentication on mount

  useEffect(() => {

    checkAuth()

  }, [])

  // Protect routes

  useEffect(() => {

    if (isLoading) return

    const inAuthGroup = segments[0] === '(auth)'

    if (!user &#x26;&#x26; !inAuthGroup) {

      router.replace('/login')

    } else if (user &#x26;&#x26; inAuthGroup) {

      router.replace('/(tabs)')

    }

  }, [user, segments, isLoading])

  async function checkAuth() {

    try {

      const token = await SecureStore.getItemAsync('authToken')

      if (token) {

        const userData = await api.getUser(token)

        setUser(userData)

      }

    } catch (error) {

      await SecureStore.deleteItemAsync('authToken')

    } finally {

      setIsLoading(false)

    }

  }

  async function signIn(credentials: Credentials) {

    const { token, user } = await api.login(credentials)

    await SecureStore.setItemAsync('authToken', token)

    setUser(user)

  }

  async function signOut() {

    await SecureStore.deleteItemAsync('authToken')

    setUser(null)

  }

  if (isLoading) {

    return <SplashScreen />

  }

  return (

    <AuthContext.Provider value={{ user, isLoading, signIn, signOut }}>

      {children}

    </AuthContext.Provider>

  )

}

export const useAuth = () => {

  const context = useContext(AuthContext)

  if (!context) throw new Error('useAuth must be used within AuthProvider')

  return context

}

Pattern 3: Offline-First with React Query

// providers/QueryProvider.tsx

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'

import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'

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

import NetInfo from '@react-native-community/netinfo'

import { onlineManager } from '@tanstack/react-query'

// Sync online status

onlineManager.setEventListener((setOnline) => {

  return NetInfo.addEventListener((state) => {

    setOnline(!!state.isConnected)

  })

})

const queryClient = new QueryClient({

  defaultOptions: {

    queries: {

      gcTime: 1000 * 60 * 60 * 24, // 24 hours

      staleTime: 1000 * 60 * 5, // 5 minutes

      retry: 2,

      networkMode: 'offlineFirst',

    },

    mutations: {

      networkMode: 'offlineFirst',

    },

  },

})

const asyncStoragePersister = createAsyncStoragePersister({

  storage: AsyncStorage,

  key: 'REACT_QUERY_OFFLINE_CACHE',

})

export function QueryProvider({ children }: { children: React.ReactNode }) {

  return (

    <PersistQueryClientProvider

      client={queryClient}

      persistOptions={{ persister: asyncStoragePersister }}

    >

      {children}

    </PersistQueryClientProvider>

  )

}

// hooks/useProducts.ts

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'

export function useProducts() {

  return useQuery({

    queryKey: ['products'],

    queryFn: api.getProducts,

    // Use stale data while revalidating

    placeholderData: (previousData) => previousData,

  })

}

export function useCreateProduct() {

  const queryClient = useQueryClient()

  return useMutation({

    mutationFn: api.createProduct,

    // Optimistic update

    onMutate: async (newProduct) => {

      await queryClient.cancelQueries({ queryKey: ['products'] })

      const previous = queryClient.getQueryData(['products'])

      queryClient.setQueryData(['products'], (old: Product[]) => [

        ...old,

        { ...newProduct, id: 'temp-' + Date.now() },

      ])

      return { previous }

    },

    onError: (err, newProduct, context) => {

      queryClient.setQueryData(['products'], context?.previous)

    },

    onSettled: () => {

      queryClient.invalidateQueries({ queryKey: ['products'] })

    },

  })

}

Pattern 4: Native Module Integration

// services/haptics.ts

import * as Haptics from "expo-haptics";

import { Platform } from "react-native";

export const haptics = {

  light: () => {

    if (Platform.OS !== "web") {

      Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);

    }

  },

  medium: () => {

    if (Platform.OS !== "web") {

      Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);

    }

  },

  heavy: () => {

    if (Platform.OS !== "web") {

      Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);

    }

  },

  success: () => {

    if (Platform.OS !== "web") {

      Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);

    }

  },

  error: () => {

    if (Platform.OS !== "web") {

      Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);

    }

  },

};

// services/biometrics.ts

import * as LocalAuthentication from "expo-local-authentication";

export async function authenticateWithBiometrics(): Promise<boolean> {

  const hasHardware = await LocalAuthentication.hasHardwareAsync();

  if (!hasHardware) return false;

  const isEnrolled = await LocalAuthentication.isEnrolledAsync();

  if (!isEnrolled) return false;

  const result = await LocalAuthentication.authenticateAsync({

    promptMessage: "Authenticate to continue",

    fallbackLabel: "Use passcode",

    disableDeviceFallback: false,

  });

  return result.success;

}

// services/notifications.ts

import * as Notifications from "expo-notifications";

import { Platform } from "react-native";

import Constants from "expo-constants";

Notifications.setNotificationHandler({

  handleNotification: async () => ({

    shouldShowAlert: true,

    shouldPlaySound: true,

    shouldSetBadge: true,

  }),

});

export async function registerForPushNotifications() {

  let token: string | undefined;

  if (Platform.OS === "android") {

    await Notifications.setNotificationChannelAsync("default", {

      name: "default",

      importance: Notifications.AndroidImportance.MAX,

      vibrationPattern: [0, 250, 250, 250],

    });

  }

  const { status: existingStatus } = await Notifications.getPermissionsAsync();

  let finalStatus = existingStatus;

  if (existingStatus !== "granted") {

    const { status } = await Notifications.requestPermissionsAsync();

    finalStatus = status;

  }

  if (finalStatus !== "granted") {

    return null;

  }

  const projectId = Constants.expoConfig?.extra?.eas?.projectId;

  token = (await Notifications.getExpoPushTokenAsync({ projectId })).data;

  return token;

}

Pattern 5: Platform-Specific Code

// components/ui/Button.tsx

import { Platform, Pressable, StyleSheet, Text, ViewStyle } from 'react-native'

import * as Haptics from 'expo-haptics'

import Animated, {

  useAnimatedStyle,

  useSharedValue,

  withSpring,

} from 'react-native-reanimated'

const AnimatedPressable = Animated.createAnimatedComponent(Pressable)

interface ButtonProps {

  title: string

  onPress: () => void

  variant?: 'primary' | 'secondary' | 'outline'

  disabled?: boolean

}

export function Button({

  title,

  onPress,

  variant = 'primary',

  disabled = false,

}: ButtonProps) {

  const scale = useSharedValue(1)

  const animatedStyle = useAnimatedStyle(() => ({

    transform: [{ scale: scale.value }],

  }))

  const handlePressIn = () => {

    scale.value = withSpring(0.95)

    if (Platform.OS !== 'web') {

      Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)

    }

  }

  const handlePressOut = () => {

    scale.value = withSpring(1)

  }

  return (

    <AnimatedPressable

      onPress={onPress}

      onPressIn={handlePressIn}

      onPressOut={handlePressOut}

      disabled={disabled}

      style={[

        styles.button,

        styles[variant],

        disabled &#x26;&#x26; styles.disabled,

        animatedStyle,

      ]}

    >

      <Text style={[styles.text, styles[`${variant}Text`]]}>{title}</Text>

    </AnimatedPressable>

  )

}

// Platform-specific files

// Button.ios.tsx - iOS-specific implementation

// Button.android.tsx - Android-specific implementation

// Button.web.tsx - Web-specific implementation

// Or use Platform.select

const styles = StyleSheet.create({

  button: {

    paddingVertical: 12,

    paddingHorizontal: 24,

    borderRadius: 8,

    alignItems: 'center',

    ...Platform.select({

      ios: {

        shadowColor: '#000',

        shadowOffset: { width: 0, height: 2 },

        shadowOpacity: 0.1,

        shadowRadius: 4,

      },

      android: {

        elevation: 4,

      },

    }),

  },

  primary: {

    backgroundColor: '#007AFF',

  },

  secondary: {

    backgroundColor: '#5856D6',

  },

  outline: {

    backgroundColor: 'transparent',

    borderWidth: 1,

    borderColor: '#007AFF',

  },

  disabled: {

    opacity: 0.5,

  },

  text: {

    fontSize: 16,

    fontWeight: '600',

  },

  primaryText: {

    color: '#FFFFFF',

  },

  secondaryText: {

    color: '#FFFFFF',

  },

  outlineText: {

    color: '#007AFF',

  },

})

Pattern 6: Performance Optimization

// components/ProductList.tsx

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

import { memo, useCallback } from 'react'

interface ProductListProps {

  products: Product[]

  onProductPress: (id: string) => void

}

// Memoize list item

const ProductItem = memo(function ProductItem({

  item,

  onPress,

}: {

  item: Product

  onPress: (id: string) => void

}) {

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

  return (

    <Pressable onPress={handlePress} style={styles.item}>

      <FastImage

        source={{ uri: item.image }}

        style={styles.image}

        resizeMode="cover"

      />

      <Text style={styles.title}>{item.name}</Text>

      <Text style={styles.price}>${item.price}</Text>

    </Pressable>

  )

})

export function ProductList({ products, onProductPress }: ProductListProps) {

  const renderItem = useCallback(

    ({ item }: { item: Product }) => (

      <ProductItem item={item} onPress={onProductPress} />

    ),

    [onProductPress]

  )

  const keyExtractor = useCallback((item: Product) => item.id, [])

  return (

    <FlashList

      data={products}

      renderItem={renderItem}

      keyExtractor={keyExtractor}

      estimatedItemSize={100}

      // Performance optimizations

      removeClippedSubviews={true}

      maxToRenderPerBatch={10}

      windowSize={5}

      // Pull to refresh

      onRefresh={onRefresh}

      refreshing={isRefreshing}

    />

  )

}

EAS Build &#x26; Submit

// eas.json

{

  "cli": { "version": ">= 5.0.0" },

  "build": {

    "development": {

      "developmentClient": true,

      "distribution": "internal",

      "ios": { "simulator": true }

    },

    "preview": {

      "distribution": "internal",

      "android": { "buildType": "apk" }

    },

    "production": {

      "autoIncrement": true

    }

  },

  "submit": {

    "production": {

      "ios": { "appleId": "your@email.com", "ascAppId": "123456789" },

      "android": { "serviceAccountKeyPath": "./google-services.json" }

    }

  }

}
# Build commands

eas build --platform ios --profile development

eas build --platform android --profile preview

eas build --platform all --profile production

# Submit to stores

eas submit --platform ios

eas submit --platform android

# OTA updates

eas update --branch production --message "Bug fixes"

Best Practices

Do's

  • Use Expo - Faster development, OTA updates, managed native code
  • FlashList over FlatList - Better performance for long lists
  • Memoize components - Prevent unnecessary re-renders
  • Use Reanimated - 60fps animations on native thread
  • Test on real devices - Simulators miss real-world issues

Don'ts

  • Don't inline styles - Use StyleSheet.create for performance
  • Don't fetch in render - Use useEffect or React Query
  • Don't ignore platform differences - Test on both iOS and Android
  • Don't store secrets in code - Use environment variables
  • Don't skip error boundaries - Mobile crashes are unforgiving
BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card