---
name: mobile-react-native
description: React Native patterns for cross-platform mobile apps. Use when building iOS/Android apps with React Native, Expo, navigation, device APIs, push notifications, or mobile-specific UI patterns.
---
# React Native / Expo Patterns
## Project Setup (Expo)
```bash
# Create new project
npx create-expo-app MyApp --template blank-typescript
cd MyApp
# Key dependencies
npx expo install expo-router react-native-safe-area-context react-native-screens
npx expo install expo-secure-store expo-notifications expo-camera
npm install @tanstack/react-query zustand react-hook-form
```
## Navigation (Expo Router — file-based)
```
app/
_layout.tsx # Root layout (providers, auth check)
(tabs)/
_layout.tsx # Tab bar layout
index.tsx # Home tab
profile.tsx # Profile tab
(auth)/
login.tsx # Login screen
register.tsx # Register screen
product/
[id].tsx # Dynamic route: /product/123
```
```typescript
// app/_layout.tsx
import { Stack } from 'expo-router'
import { useAuthStore } from '@/store/auth'
export default function RootLayout() {
const isLoggedIn = useAuthStore(s => !!s.token)
return (
)
}
// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router'
import { Home, User, Settings } from 'lucide-react-native'
export default function TabsLayout() {
return (
}} />
}} />
)
}
```
## Screen Component
```typescript
// app/(tabs)/index.tsx
import { View, Text, FlatList, StyleSheet, RefreshControl } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useQuery } from '@tanstack/react-query'
import { router } from 'expo-router'
export default function HomeScreen() {
const { data, isLoading, refetch, isRefetching } = useQuery({
queryKey: ['posts'],
queryFn: () => api.getPosts(),
})
return (
item.id}
refreshControl={}
renderItem={({ item }) => (
router.push(`/post/${item.id}`)}
/>
)}
ListEmptyComponent={isLoading ? : }
/>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#fff' },
})
```
## Secure Storage (tokens)
```typescript
import * as SecureStore from 'expo-secure-store'
export const tokenStorage = {
async get(key: string): Promise {
return SecureStore.getItemAsync(key)
},
async set(key: string, value: string): Promise {
await SecureStore.setItemAsync(key, value)
},
async delete(key: string): Promise {
await SecureStore.deleteItemAsync(key)
},
}
// Zustand with secure storage
import { create } from 'zustand'
import { createJSONStorage, persist } from 'zustand/middleware'
const secureStorage = createJSONStorage(() => ({
getItem: (key) => tokenStorage.get(key),
setItem: (key, value) => tokenStorage.set(key, value),
removeItem: (key) => tokenStorage.delete(key),
}))
export const useAuthStore = create(persist(
(set) => ({
token: null as string | null,
setToken: (token: string) => set({ token }),
logout: () => set({ token: null }),
}),
{ name: 'auth', storage: secureStorage }
))
```
## Push Notifications
```typescript
import * as Notifications from 'expo-notifications'
import * as Device from 'expo-device'
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
})
export async function registerForPushNotifications(): Promise {
if (!Device.isDevice) return null // Simulator doesn't support push
const { status: existing } = await Notifications.getPermissionsAsync()
let finalStatus = existing
if (existing !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync()
finalStatus = status
}
if (finalStatus !== 'granted') return null
const token = await Notifications.getExpoPushTokenAsync({
projectId: Constants.expoConfig?.extra?.eas?.projectId,
})
return token.data
}
// Use in component
useEffect(() => {
registerForPushNotifications().then(token => {
if (token) api.savePushToken(token)
})
const sub = Notifications.addNotificationReceivedListener(notification => {
console.log('Notification received:', notification)
})
return () => sub.remove()
}, [])
```
## Camera & Image Picker
```typescript
import * as ImagePicker from 'expo-image-picker'
export async function pickImage(): Promise {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync()
if (status !== 'granted') {
Alert.alert('Permission needed', 'Please allow access to your photo library')
return null
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
aspect: [1, 1],
quality: 0.8,
})
if (result.canceled) return null
return result.assets[0].uri
}
// Upload to API
async function uploadAvatar(uri: string): Promise {
const formData = new FormData()
formData.append('file', {
uri,
name: 'avatar.jpg',
type: 'image/jpeg',
} as any)
const response = await fetch(`${API_URL}/upload`, {
method: 'POST',
body: formData,
headers: { 'Content-Type': 'multipart/form-data' },
})
const { url } = await response.json()
return url
}
```
## Styling Patterns
```typescript
import { StyleSheet, Platform } from 'react-native'
// Platform-specific styles
const styles = StyleSheet.create({
shadow: {
...Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
},
android: {
elevation: 4,
},
}),
},
card: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 16,
marginHorizontal: 16,
marginVertical: 8,
},
})
// NativeWind (Tailwind for React Native)
//
// Hello
//
```
## Rules
- Use Expo Router for navigation (file-based, better DX than React Navigation)
- Always store sensitive data in SecureStore — never AsyncStorage for tokens
- Use FlatList not ScrollView+map for long lists (virtualization)
- Test on real device early — simulators miss camera, notifications, biometrics
- Platform.select() for iOS/Android differences in shadows, fonts, keyboard
- Request permissions lazily (only when needed) — not at app startup
- Use SafeAreaView for all screens (notch/home indicator safe zones)
- Bundle size matters: use expo-image not for better performance