mirror of
https://github.com/wshobson/agents.git
synced 2026-03-18 17:47:16 +00:00
Remove references to non-existent resource files (references/, assets/, scripts/, examples/) from 115 skill SKILL.md files. These sections pointed to directories and files that were never created, causing confusion when users install skills. Also fix broken Code of Conduct links in issue templates to use absolute GitHub URLs instead of relative paths that 404.
667 lines
17 KiB
Markdown
667 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
|