Install any skill in seconds. Free to start, no credit card required.
Get Started Free →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.
.claude/skills/dicklesworthstone-react-native-architecture/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-05 | ✗→✓ | ▲ Improved | 176% | 0% |
| case-03 | ✗→✓ | ▲ Improved | 180% | 0% |
| case-09 | ✗→✓ | ▲ Improved | 203% | 0% |
| case-13 | ✗→✓ | ▲ Improved | 233% | 0% |
| case-12 | ✓→✗ | ▼ Worse | 108% | 0% |
Production-ready patterns for React Native development with Expo, including navigation, state management, native modules, and offline-first architecture.
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| 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 |
bash# 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
typescript// 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> ) }
typescript// 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' }, })
typescript// 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 && !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 }
typescript// 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'] }) }, }) }
typescript// 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; }
typescript// 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 && 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', }, })
typescript// 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} /> ) }
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 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"
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-05 | fail→pass | 11,672 | 9,786 | -16% | 1 | 1 | 0% | 2,418 | 6,670 | +176% | 0 | 0 | — |
case-03 | fail→pass | 12,590 | 13,293 | +6% | 1 | 1 | 0% | 2,651 | 7,414 | +180% | 0 | 0 | — |
case-04 | pass→pass | 10,792 | 9,501 | -12% | 1 | 1 | 0% | 1,863 | 6,397 | +243% | 0 | 0 | — |
case-01 | pass→pass | 26,005 | 19,502 | -25% | 1 | 1 | 0% | 5,351 | 8,734 | +63% | 0 | 0 | — |
case-02 | pass→pass | 22,340 | 22,297 | -0% | 1 | 1 | 0% | 4,404 | 9,161 | +108% | 0 | 0 | — |
case-06 | pass→pass | 7,162 | 6,280 | -12% | 1 | 1 | 0% | 1,268 | 5,911 | +366% | 0 | 0 | — |
case-07 | pass→pass | 14,730 | 14,486 | -2% | 1 | 1 | 0% | 2,875 | 7,515 | +161% | 0 | 0 | — |
case-08 | pass→pass | 13,446 | 12,147 | -10% | 1 | 1 | 0% | 2,437 | 6,876 | +182% | 0 | 0 | — |
case-09 | fail→pass | 13,658 | 15,312 | +12% | 1 | 1 | 0% | 2,616 | 7,926 | +203% | 0 | 0 | — |
case-15 | pass→pass | 9,060 | 5,883 | -35% | 1 | 1 | 0% | 1,658 | 5,798 | +250% | 0 | 0 | — |
case-10 | pass→pass | 12,547 | 52,448 | +318% | 1 | 1 | 0% | 2,515 | 9,179 | +265% | 0 | 0 | — |
case-11 | pass→pass | 12,030 | 11,308 | -6% | 1 | 1 | 0% | 2,413 | 6,989 | +190% | 0 | 0 | — |
case-12 | pass→fail | 20,802 | 19,485 | -6% | 1 | 1 | 0% | 4,300 | 8,949 | +108% | 0 | 0 | — |
case-13 | fail→pass | 11,557 | 8,696 | -25% | 1 | 1 | 0% | 1,940 | 6,459 | +233% | 0 | 0 | — |
case-14 | fail→fail | 17,726 | 21,310 | +20% | 1 | 1 | 0% | 2,834 | 7,682 | +171% | 0 | 0 | — |
case-16 | pass→pass | 7,366 | 5,866 | -20% | 1 | 1 | 0% | 1,337 | 5,655 | +323% | 0 | 0 | — |
case-17 | pass→pass | 10,692 | 5,421 | -49% | 1 | 1 | 0% | 1,955 | 5,682 | +191% | 0 | 0 | — |
case-18 | fail→fail | 15,938 | 9,678 | -39% | 1 | 1 | 0% | 2,879 | 6,505 | +126% | 0 | 0 | — |
case-19 | pass→pass | 12,862 | 16,058 | +25% | 1 | 1 | 0% | 2,310 | 7,699 | +233% | 0 | 0 | — |
case-20 | pass→pass | 9,077 | 6,777 | -25% | 1 | 1 | 0% | 1,869 | 6,057 | +224% | 0 | 0 | — |
case-21 | pass→pass | 17,258 | 18,641 | +8% | 1 | 1 | 0% | 3,413 | 8,452 | +148% | 0 | 0 | — |
case-22 | pass→pass | 18,390 | 9,854 | -46% | 1 | 1 | 0% | 3,537 | 6,579 | +86% | 0 | 0 | — |
case-23 | pass→pass | 12,603 | 13,989 | +11% | 1 | 1 | 0% | 2,604 | 7,692 | +195% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 23 cases were attempted. The headline lift of +13 percentage points is the difference between those two pass rates over the 23 comparable cases. 2 cases got worse with the skill loaded, and they are included in that figure.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.