mirror of
https://github.com/wshobson/agents.git
synced 2026-03-18 17:47:16 +00:00
674 lines
17 KiB
Markdown
674 lines
17 KiB
Markdown
---
|
|
name: react-native-architecture
|
|
description: 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 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>
|
|
)
|
|
}
|
|
```
|
|
|
|
## Patterns
|
|
|
|
### Pattern 1: Expo Router Navigation
|
|
|
|
```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' },
|
|
})
|
|
```
|
|
|
|
### Pattern 2: Authentication Flow
|
|
|
|
```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
|
|
}
|
|
```
|
|
|
|
### Pattern 3: Offline-First with React Query
|
|
|
|
```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'] })
|
|
},
|
|
})
|
|
}
|
|
```
|
|
|
|
### Pattern 4: Native Module Integration
|
|
|
|
```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;
|
|
}
|
|
```
|
|
|
|
### Pattern 5: Platform-Specific Code
|
|
|
|
```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',
|
|
},
|
|
})
|
|
```
|
|
|
|
### Pattern 6: Performance Optimization
|
|
|
|
```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}
|
|
/>
|
|
)
|
|
}
|
|
```
|
|
|
|
## 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 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](https://docs.expo.dev/)
|
|
- [Expo Router](https://docs.expo.dev/router/introduction/)
|
|
- [React Native Performance](https://reactnative.dev/docs/performance)
|
|
- [FlashList](https://shopify.github.io/flash-list/)
|