React Native / Expo Skill
Patterns for building React Native apps with Expo. Reference this when building mobile features.
Project Structure
code
mobile/ ├── app/ # Expo Router screens │ ├── (tabs)/ # Tab navigation │ │ ├── _layout.tsx │ │ ├── index.tsx # Schedule tab │ │ ├── plans.tsx # Plans tab │ │ ├── stats.tsx # Stats tab │ │ └── coach.tsx # AI Coach tab │ ├── (auth)/ # Auth screens │ │ ├── sign-in.tsx │ │ └── sign-up.tsx │ ├── workout/[id].tsx # Workout detail │ └── _layout.tsx # Root layout ├── components/ │ ├── ui/ # Base components │ └── features/ # Feature components ├── hooks/ ├── lib/ │ ├── api.ts # API client (tRPC or fetch) │ ├── auth.ts # Auth helpers │ └── storage.ts # Async storage ├── constants/ └── types/
Navigation (Expo Router)
Tab Layout
typescript
// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router'
import { Calendar, Trophy, BarChart, MessageCircle } from 'lucide-react-native'
export default function TabLayout() {
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: '#0066FF',
tabBarInactiveTintColor: '#666',
headerShown: false,
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Schedule',
tabBarIcon: ({ color, size }) => (
<Calendar size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="plans"
options={{
title: 'Plans',
tabBarIcon: ({ color, size }) => (
<Trophy size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="stats"
options={{
title: 'Stats',
tabBarIcon: ({ color, size }) => (
<BarChart size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="coach"
options={{
title: 'Coach',
tabBarIcon: ({ color, size }) => (
<MessageCircle size={size} color={color} />
),
}}
/>
</Tabs>
)
}
Stack Navigation
typescript
// app/workout/[id].tsx
import { useLocalSearchParams, Stack } from 'expo-router'
export default function WorkoutDetail() {
const { id } = useLocalSearchParams<{ id: string }>()
return (
<>
<Stack.Screen options={{ title: 'Workout' }} />
<WorkoutContent id={id} />
</>
)
}
Styling
Use StyleSheet (Not Tailwind)
typescript
import { StyleSheet, View, Text } from 'react-native'
export function WorkoutCard({ workout }: { workout: Workout }) {
return (
<View style={styles.card}>
<Text style={styles.title}>{workout.title}</Text>
<Text style={styles.subtitle}>{workout.duration} min</Text>
</View>
)
}
const styles = StyleSheet.create({
card: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 16,
marginBottom: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3, // Android shadow
},
title: {
fontSize: 18,
fontWeight: '600',
color: '#111',
},
subtitle: {
fontSize: 14,
color: '#666',
marginTop: 4,
},
})
Theming
typescript
// constants/theme.ts
export const theme = {
colors: {
primary: '#0066FF',
secondary: '#10B981',
background: '#F5F5F5',
surface: '#FFFFFF',
text: '#111111',
textSecondary: '#666666',
error: '#EF4444',
warning: '#F59E0B',
},
spacing: {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 32,
},
borderRadius: {
sm: 8,
md: 12,
lg: 16,
full: 9999,
},
}
// Usage
const styles = StyleSheet.create({
button: {
backgroundColor: theme.colors.primary,
padding: theme.spacing.md,
borderRadius: theme.borderRadius.md,
},
})
Data Fetching
React Query Setup
typescript
// lib/api.ts
import { QueryClient } from '@tanstack/react-query'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 1 minute
retry: 2,
},
},
})
// API base
const API_URL = process.env.EXPO_PUBLIC_API_URL
export async function api<T>(
endpoint: string,
options?: RequestInit
): Promise<T> {
const token = await getAuthToken()
const response = await fetch(`${API_URL}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
...options?.headers,
},
})
if (!response.ok) {
throw new Error(`API error: ${response.status}`)
}
return response.json()
}
Custom Hooks
typescript
// hooks/use-workouts.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api'
export function useWorkouts(date: string) {
return useQuery({
queryKey: ['workouts', date],
queryFn: () => api<Workout[]>(`/workouts?date=${date}`),
})
}
export function useCompleteWorkout() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data: CompleteWorkoutData) =>
api('/workouts/complete', {
method: 'POST',
body: JSON.stringify(data),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['workouts'] })
},
})
}
Local Storage
Async Storage
typescript
// lib/storage.ts
import AsyncStorage from '@react-native-async-storage/async-storage'
export const storage = {
async get<T>(key: string): Promise<T | null> {
const value = await AsyncStorage.getItem(key)
return value ? JSON.parse(value) : null
},
async set<T>(key: string, value: T): Promise<void> {
await AsyncStorage.setItem(key, JSON.stringify(value))
},
async remove(key: string): Promise<void> {
await AsyncStorage.removeItem(key)
},
}
// Keys
export const STORAGE_KEYS = {
AUTH_TOKEN: 'auth_token',
USER: 'user',
ONBOARDING_COMPLETE: 'onboarding_complete',
CACHED_WORKOUTS: 'cached_workouts',
}
Secure Storage (for tokens)
typescript
// lib/secure-storage.ts
import * as SecureStore from 'expo-secure-store'
export const secureStorage = {
async get(key: string): Promise<string | null> {
return SecureStore.getItemAsync(key)
},
async set(key: string, value: string): Promise<void> {
await SecureStore.setItemAsync(key, value)
},
async remove(key: string): Promise<void> {
await SecureStore.deleteItemAsync(key)
},
}
Authentication
Auth Context
typescript
// context/auth.tsx
import { createContext, useContext, useEffect, useState } from 'react'
import { useRouter, useSegments } from 'expo-router'
import { secureStorage } from '@/lib/secure-storage'
interface AuthContextType {
user: User | null
signIn: (token: string, user: User) => Promise<void>
signOut: () => Promise<void>
isLoading: boolean
}
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 router = useRouter()
const segments = useSegments()
// Check auth on mount
useEffect(() => {
loadUser()
}, [])
// Redirect based on auth state
useEffect(() => {
if (isLoading) return
const inAuthGroup = segments[0] === '(auth)'
if (!user && !inAuthGroup) {
router.replace('/sign-in')
} else if (user && inAuthGroup) {
router.replace('/')
}
}, [user, segments, isLoading])
async function loadUser() {
try {
const token = await secureStorage.get('auth_token')
if (token) {
// Validate token and get user
const user = await api<User>('/auth/me')
setUser(user)
}
} catch {
await secureStorage.remove('auth_token')
} finally {
setIsLoading(false)
}
}
async function signIn(token: string, user: User) {
await secureStorage.set('auth_token', token)
setUser(user)
}
async function signOut() {
await secureStorage.remove('auth_token')
setUser(null)
}
return (
<AuthContext.Provider value={{ user, signIn, signOut, isLoading }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (!context) throw new Error('useAuth must be used within AuthProvider')
return context
}
Offline Support
Offline Queue
typescript
// lib/offline-queue.ts
import NetInfo from '@react-native-community/netinfo'
import { storage, STORAGE_KEYS } from './storage'
interface QueuedAction {
id: string
type: 'COMPLETE_WORKOUT' | 'LOG_ACTIVITY' | 'SYNC_DATA'
payload: unknown
timestamp: number
}
class OfflineQueue {
private queue: QueuedAction[] = []
async init() {
this.queue = await storage.get(STORAGE_KEYS.OFFLINE_QUEUE) ?? []
// Listen for connectivity
NetInfo.addEventListener(state => {
if (state.isConnected) {
this.processQueue()
}
})
}
async add(action: Omit<QueuedAction, 'id' | 'timestamp'>) {
const queuedAction: QueuedAction = {
...action,
id: crypto.randomUUID(),
timestamp: Date.now(),
}
this.queue.push(queuedAction)
await this.persist()
// Try to process immediately
this.processQueue()
}
private async processQueue() {
const state = await NetInfo.fetch()
if (!state.isConnected || this.queue.length === 0) return
const toProcess = [...this.queue]
for (const action of toProcess) {
try {
await this.execute(action)
this.queue = this.queue.filter(a => a.id !== action.id)
} catch {
// Keep in queue for retry
break
}
}
await this.persist()
}
private async execute(action: QueuedAction) {
switch (action.type) {
case 'COMPLETE_WORKOUT':
await api('/workouts/complete', {
method: 'POST',
body: JSON.stringify(action.payload),
})
break
// ... other action types
}
}
private async persist() {
await storage.set(STORAGE_KEYS.OFFLINE_QUEUE, this.queue)
}
}
export const offlineQueue = new OfflineQueue()
Push Notifications
Setup
typescript
// lib/notifications.ts
import * as Notifications from 'expo-notifications'
import * as Device from 'expo-device'
import { Platform } from 'react-native'
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
})
export async function registerForPushNotifications() {
if (!Device.isDevice) {
console.log('Push notifications require a physical device')
return null
}
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 token = await Notifications.getExpoPushTokenAsync()
// Android channel setup
if (Platform.OS === 'android') {
Notifications.setNotificationChannelAsync('workouts', {
name: 'Workouts',
importance: Notifications.AndroidImportance.HIGH,
vibrationPattern: [0, 250, 250, 250],
})
}
return token.data
}
Lists & Performance
FlashList for Large Lists
typescript
import { FlashList } from '@shopify/flash-list'
function WorkoutList({ workouts }: { workouts: Workout[] }) {
return (
<FlashList
data={workouts}
renderItem={({ item }) => <WorkoutCard workout={item} />}
estimatedItemSize={100}
keyExtractor={(item) => item.id}
/>
)
}
Memoization
typescript
import { memo, useCallback } from 'react'
const WorkoutCard = memo(function WorkoutCard({
workout,
onPress
}: {
workout: Workout
onPress: (id: string) => void
}) {
const handlePress = useCallback(() => {
onPress(workout.id)
}, [workout.id, onPress])
return (
<Pressable onPress={handlePress} style={styles.card}>
<Text>{workout.title}</Text>
</Pressable>
)
})
Haptics & Audio
Haptic Feedback
typescript
import * as Haptics from 'expo-haptics' // Light tap for buttons await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light) // Medium for important actions await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium) // Success notification await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success) // Error notification await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error)
Audio Cues (for workouts)
typescript
import { Audio } from 'expo-av'
const sounds = {
intervalStart: require('@/assets/sounds/beep-start.mp3'),
intervalEnd: require('@/assets/sounds/beep-end.mp3'),
workoutComplete: require('@/assets/sounds/complete.mp3'),
}
export async function playSound(sound: keyof typeof sounds) {
const { sound: audioSound } = await Audio.Sound.createAsync(sounds[sound])
await audioSound.playAsync()
// Unload after playing
audioSound.setOnPlaybackStatusUpdate(status => {
if (status.isLoaded && status.didJustFinish) {
audioSound.unloadAsync()
}
})
}
Testing
typescript
// __tests__/WorkoutCard.test.tsx
import { render, screen, fireEvent } from '@testing-library/react-native'
import { WorkoutCard } from '@/components/WorkoutCard'
describe('WorkoutCard', () => {
const mockWorkout = {
id: '1',
title: 'Easy Run',
duration: 45,
sport: 'RUNNING',
}
it('renders workout title', () => {
render(<WorkoutCard workout={mockWorkout} onPress={() => {}} />)
expect(screen.getByText('Easy Run')).toBeTruthy()
})
it('calls onPress with workout id', () => {
const onPress = jest.fn()
render(<WorkoutCard workout={mockWorkout} onPress={onPress} />)
fireEvent.press(screen.getByText('Easy Run'))
expect(onPress).toHaveBeenCalledWith('1')
})
})
Best Practices
- •Use Expo SDK when possible — avoid native modules
- •Optimize images — Use expo-image, not Image
- •Minimize re-renders — Use memo, useCallback
- •Handle offline — Queue actions, cache data
- •Test on device — Simulators miss performance issues
- •Use FlashList for lists over 50 items
- •Secure sensitive data — Use SecureStore for tokens