Claude Agent Skill · by Wshobson

React Native Architecture

Built for teams shipping React Native apps to production, this covers the architectural decisions that matter: Expo Router for file-based navigation, offline-fi

Install
Terminal · npx
$npx skills add https://github.com/wshobson/agents --skill react-native-architecture
Works with Paperclip

How React Native Architecture fits into a Paperclip company.

React Native Architecture drops into any Paperclip agent that handles this kind of work. Assign it to a specialist inside a pre-configured PaperclipOrg company and the skill becomes available on every heartbeat — no prompt engineering, no tool wiring.

S
SaaS FactoryPaired

Pre-configured AI company — 18 agents, 18 skills, one-time purchase.

$27$59
Explore pack
Source file
SKILL.md666 lines
Expand
---name: react-native-architecturedescription: Build production React Native apps with Expo, navigation, native modules, offline sync, and cross-platform patterns. Use when developing mobile apps, implementing native integrations, or architecting React Native projects.--- # 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 ```bash# Create new Expo projectnpx create-expo-app@latest my-app -t expo-template-blank-typescript # Install essential dependenciesnpx expo install expo-router expo-status-bar react-native-safe-area-contextnpx expo install @react-native-async-storage/async-storagenpx expo install expo-secure-store expo-haptics``` ```typescript// app/_layout.tsximport { 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 ```typescript// app/(tabs)/_layout.tsximport { 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 routeimport { useLocalSearchParams } from 'expo-router' export default function ProfileScreen() {  const { id } = useLocalSearchParams<{ id: string }>()   return <UserProfile userId={id} />} // Navigation from anywhereimport { router } from 'expo-router' // Programmatic navigationrouter.push('/profile/123')router.replace('/login')router.back() // With paramsrouter.push({  pathname: '/product/[id]',  params: { id: '123', referrer: 'home' },})``` ### Pattern 2: Authentication Flow ```typescript// providers/AuthProvider.tsximport { 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 && !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 <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 ```typescript// providers/QueryProvider.tsximport { 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 statusonlineManager.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.tsimport { 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 ```typescript// services/haptics.tsimport * 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.tsimport * 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.tsimport * 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 ```typescript// components/ui/Button.tsximport { 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 && 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.selectconst 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 ```typescript// components/ProductList.tsximport { FlashList } from '@shopify/flash-list'import { memo, useCallback } from 'react' interface ProductListProps {  products: Product[]  onProductPress: (id: string) => void} // Memoize list itemconst 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 & Submit ```json// 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" }    }  }}``` ```bash# Build commandseas build --platform ios --profile developmenteas build --platform android --profile previeweas build --platform all --profile production # Submit to storeseas submit --platform ioseas submit --platform android # OTA updateseas 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