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
- 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 (
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 (
// app/(tabs)/profile/[id].tsx - Dynamic route import { useLocalSearchParams } from 'expo-router'
export default function ProfileScreen() { const { id } = useLocalSearchParams<{ id: string }>()
return
// 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
const AuthContext = createContext
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState
// Check authentication on mount useEffect(() => { checkAuth() }, [])
// Protect routes useEffect(() => { if (isLoading) return
const inAuthGroup = segments[0] === '(auth)'
if (!user && !inAuthGroup) {
router.replace('/login')
} else if (user && 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
return (
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 (
// 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
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 (
// 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 (
export function ProductList({ products, onProductPress }: ProductListProps) {
const renderItem = useCallback(
({ item }: { item: Product }) => (
const keyExtractor = useCallback((item: Product) => item.id, [])
return (
EAS Build & 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 Resources Expo Documentation Expo Router React Native Performance FlashList