
Expo React Native Coder
- 336 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
expo-react-native-coder is a dot-skills agent skill that guides Expo and React Native mobile development tasks for developers building cross-platform iOS and Android applications.
About
expo-react-native-coder is a skill from the pproenca/dot-skills repository focused on Expo and React Native development workflows. It assists coding agents with mobile app tasks such as implementing screens, wiring navigation, applying Expo APIs, and following React Native component patterns during feature work. Developers reach for expo-react-native-coder when agent-assisted sessions need mobile-specific guidance instead of generic web React patterns. The skill fits active build phases where JavaScript or TypeScript mobile code must be produced, refactored, or debugged within an Expo-managed project structure.
- expo-react-native-coder
Expo React Native Coder by the numbers
- 336 all-time installs (skills.sh)
- +10 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,199 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill expo-react-native-coderAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 336 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do you build Expo React Native features with agents?
Use expo-react-native-coder for development tasks
Who is it for?
Mobile developers using Expo and React Native who want agent assistance for screens, navigation, and cross-platform feature implementation.
Skip if: Native Swift or Kotlin-only projects or teams building web-only React apps without a mobile target.
When should I use this skill?
An Expo or React Native project needs new screens, navigation, components, or mobile-specific feature implementation.
What you get
Expo React Native components, navigation wiring, screen implementations, and mobile feature code changes.
- React Native components
- navigation setup
- mobile screen implementations
Files
Expo React Native Coder Best Practices
Comprehensive feature development guide for Expo React Native applications. Contains 50 rules across 10 categories, covering everything from project setup to testing. Includes production-ready code templates for common features.
When to Apply
Reference these guidelines when:
- Setting up a new Expo project with TypeScript
- Building navigation with Expo Router (tabs, stacks, drawers, modals)
- Creating screens (list, detail, form, settings)
- Implementing authentication flows with protected routes
- Configuring deep linking and universal links
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Project Setup & Configuration | CRITICAL | setup- |
| 2 | Routing & Navigation | CRITICAL | route- |
| 3 | Screen Patterns & Layouts | HIGH | screen- |
| 4 | Data Fetching & State | HIGH | data- |
| 5 | Authentication & Security | HIGH | auth- |
| 6 | Deep Linking & Universal Links | HIGH | link- |
| 7 | Native UX Patterns | MEDIUM-HIGH | ux- |
| 8 | Forms & User Input | MEDIUM | form- |
| 9 | Assets & Theming | MEDIUM | asset- |
| 10 | Error Handling & Testing | MEDIUM | test- |
Quick Reference
1. Project Setup & Configuration (CRITICAL)
- `setup-typescript-config` - Configure TypeScript with strict mode
- `setup-app-config-typescript` - Use typed app.config.ts
- `setup-environment-variables` - EXPO_PUBLIC_ prefix for client vars
- `setup-eas-build-profiles` - EAS build profiles per environment
- `setup-development-build` - Development builds vs Expo Go
2. Routing & Navigation (CRITICAL)
- `route-file-based-routing` - File-based routing with Expo Router
- `route-tab-navigator` - Tab navigator with route groups
- `route-dynamic-segments` - Dynamic route segments [param]
- `route-stack-within-tabs` - Nested stack in tabs
- `route-modal-presentation` - Modal screen presentation
- `route-typed-routes` - Enable typed routes
- `route-drawer-navigator` - Drawer navigator setup
3. Screen Patterns & Layouts (HIGH)
- `screen-list-flashlist` - FlashList for large lists
- `screen-detail-params` - Pass minimal data via params
- `screen-loading-state` - Loading and error states
- `screen-pull-to-refresh` - Pull-to-refresh pattern
- `screen-header-options` - Configure screen headers
- `screen-settings-list` - Settings screen with SectionList
4. Data Fetching & State (HIGH)
- `data-api-routes` - Server-side API routes
- `data-secure-store` - SecureStore for sensitive data
- `data-sqlite-local` - SQLite for complex local data
- `data-fetch-on-focus` - Refetch on screen focus
- `data-async-storage-simple` - AsyncStorage for preferences
- `data-abort-controller` - Cancel fetch on unmount
5. Authentication & Security (HIGH)
- `auth-protected-routes` - Stack.Protected guards
- `auth-context-provider` - Auth context with session
- `auth-oauth-flow` - OAuth with AuthSession
- `auth-login-form` - Login form with validation
- `auth-splash-loading` - Splash screen during auth check
6. Deep Linking & Universal Links (HIGH)
- `link-deep-linking-scheme` - Custom URL scheme
- `link-universal-links-ios` - iOS Universal Links
- `link-android-app-links` - Android App Links
- `link-handle-incoming` - Handle incoming URLs
7. Native UX Patterns (MEDIUM-HIGH)
- `ux-safe-area-insets` - SafeAreaView for notches
- `ux-status-bar` - Status bar styling
- `ux-haptic-feedback` - Haptic feedback on actions
- `ux-gesture-handler` - Gesture handler for swipes
- `ux-keyboard-avoiding` - KeyboardAvoidingView
8. Forms & User Input (MEDIUM)
- `form-text-input-config` - TextInput keyboard types
- `form-controlled-inputs` - Controlled inputs with useState
- `form-submit-button-state` - Disable button during submit
- `form-dismiss-keyboard` - Dismiss keyboard on tap outside
9. Assets & Theming (MEDIUM)
- `asset-image-optimization` - expo-image for caching
- `asset-font-loading` - Load fonts with useFonts
- `asset-vector-icons` - @expo/vector-icons
- `asset-splash-screen` - Splash screen configuration
10. Error Handling & Testing (MEDIUM)
- `test-jest-setup` - Jest with jest-expo preset
- `test-component-testing` - Testing Library for components
- `test-error-boundary` - Error boundaries
- `test-e2e-maestro` - Maestro E2E testing
Code Templates
Production-ready templates are available in assets/templates/:
| Template | Description |
|---|---|
layouts/tab-layout.tsx | Bottom tab navigator with icons |
layouts/auth-layout.tsx | Root layout with protected routes |
screens/list-screen.tsx | List with FlashList, refresh, states |
screens/detail-screen.tsx | Detail screen with param handling |
screens/form-screen.tsx | Form with validation, keyboard handling |
hooks/use-auth.tsx | Auth context with SecureStore |
components/error-boundary.tsx | Error boundary component |
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions - Category structure and impact levels
- Rule template - Template for adding new rules
Full Compiled Document
For a single comprehensive document with all rules, see AGENTS.md.
Reference Files
| File | Description |
|---|---|
| AGENTS.md | Complete compiled guide with all rules |
| references/_sections.md | Category definitions and ordering |
| assets/templates/ | Production-ready code templates |
| metadata.json | Version and reference information |
<?xml version="1.0" encoding="UTF-8"?> <skills> <skill name="expo-react-native-coder"> <description>Comprehensive Expo React Native feature development guide. This skill should be used when building mobile app screens, navigation, data fetching, authentication, deep linking, or native UX patterns with Expo. Triggers on tasks involving Expo Router, React Native components, mobile forms, or app configuration.</description> <content><![CDATA[
Expo React Native Coder Best Practices
Comprehensive feature development guide for Expo React Native applications. Contains 50 rules across 10 categories, covering everything from project setup to testing. Includes production-ready code templates for common features.
When to Apply
Reference these guidelines when:
- Setting up a new Expo project with TypeScript
- Building navigation with Expo Router (tabs, stacks, drawers, modals)
- Creating screens (list, detail, form, settings)
- Implementing authentication flows with protected routes
- Configuring deep linking and universal links
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Project Setup & Configuration | CRITICAL | setup- |
| 2 | Routing & Navigation | CRITICAL | route- |
| 3 | Screen Patterns & Layouts | HIGH | screen- |
| 4 | Data Fetching & State | HIGH | data- |
| 5 | Authentication & Security | HIGH | auth- |
| 6 | Deep Linking & Universal Links | HIGH | link- |
| 7 | Native UX Patterns | MEDIUM-HIGH | ux- |
| 8 | Forms & User Input | MEDIUM | form- |
| 9 | Assets & Theming | MEDIUM | asset- |
| 10 | Error Handling & Testing | MEDIUM | test- |
Quick Reference
1. Project Setup & Configuration (CRITICAL)
- `setup-typescript-config` - Configure TypeScript with strict mode
- `setup-app-config-typescript` - Use typed app.config.ts
- `setup-environment-variables` - EXPO_PUBLIC_ prefix for client vars
- `setup-eas-build-profiles` - EAS build profiles per environment
- `setup-development-build` - Development builds vs Expo Go
2. Routing & Navigation (CRITICAL)
- `route-file-based-routing` - File-based routing with Expo Router
- `route-tab-navigator` - Tab navigator with route groups
- `route-dynamic-segments` - Dynamic route segments [param]
- `route-stack-within-tabs` - Nested stack in tabs
- `route-modal-presentation` - Modal screen presentation
- `route-typed-routes` - Enable typed routes
- `route-drawer-navigator` - Drawer navigator setup
3. Screen Patterns & Layouts (HIGH)
- `screen-list-flashlist` - FlashList for large lists
- `screen-detail-params` - Pass minimal data via params
- `screen-loading-state` - Loading and error states
- `screen-pull-to-refresh` - Pull-to-refresh pattern
- `screen-header-options` - Configure screen headers
- `screen-settings-list` - Settings screen with SectionList
4. Data Fetching & State (HIGH)
- `data-api-routes` - Server-side API routes
- `data-secure-store` - SecureStore for sensitive data
- `data-sqlite-local` - SQLite for complex local data
- `data-fetch-on-focus` - Refetch on screen focus
- `data-async-storage-simple` - AsyncStorage for preferences
- `data-abort-controller` - Cancel fetch on unmount
5. Authentication & Security (HIGH)
- `auth-protected-routes` - Stack.Protected guards
- `auth-context-provider` - Auth context with session
- `auth-oauth-flow` - OAuth with AuthSession
- `auth-login-form` - Login form with validation
- `auth-splash-loading` - Splash screen during auth check
6. Deep Linking & Universal Links (HIGH)
- `link-deep-linking-scheme` - Custom URL scheme
- `link-universal-links-ios` - iOS Universal Links
- `link-android-app-links` - Android App Links
- `link-handle-incoming` - Handle incoming URLs
7. Native UX Patterns (MEDIUM-HIGH)
- `ux-safe-area-insets` - SafeAreaView for notches
- `ux-status-bar` - Status bar styling
- `ux-haptic-feedback` - Haptic feedback on actions
- `ux-gesture-handler` - Gesture handler for swipes
- `ux-keyboard-avoiding` - KeyboardAvoidingView
8. Forms & User Input (MEDIUM)
- `form-text-input-config` - TextInput keyboard types
- `form-controlled-inputs` - Controlled inputs with useState
- `form-submit-button-state` - Disable button during submit
- `form-dismiss-keyboard` - Dismiss keyboard on tap outside
9. Assets & Theming (MEDIUM)
- `asset-image-optimization` - expo-image for caching
- `asset-font-loading` - Load fonts with useFonts
- `asset-vector-icons` - @expo/vector-icons
- `asset-splash-screen` - Splash screen configuration
10. Error Handling & Testing (MEDIUM)
- `test-jest-setup` - Jest with jest-expo preset
- `test-component-testing` - Testing Library for components
- `test-error-boundary` - Error boundaries
- `test-e2e-maestro` - Maestro E2E testing
Code Templates
Production-ready templates are available in assets/templates/:
| Template | Description |
|---|---|
layouts/tab-layout.tsx | Bottom tab navigator with icons |
layouts/auth-layout.tsx | Root layout with protected routes |
screens/list-screen.tsx | List with FlashList, refresh, states |
screens/detail-screen.tsx | Detail screen with param handling |
screens/form-screen.tsx | Form with validation, keyboard handling |
hooks/use-auth.tsx | Auth context with SecureStore |
components/error-boundary.tsx | Error boundary component |
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions - Category structure and impact levels
- Rule template - Template for adding new rules
Full Compiled Document
For a single comprehensive document with all rules, see AGENTS.md.
Reference Files
| File | Description |
|---|---|
| AGENTS.md | Complete compiled guide with all rules |
| references/_sections.md | Category definitions and ordering |
| assets/templates/ | Production-ready code templates |
| metadata.json | Version and reference information |
]]></content> </skill> </skills>
Rule Title Here
Brief explanation (1-3 sentences) of WHY this pattern matters. Focus on the problem it solves or benefit it provides.
Incorrect (description of what's wrong):
// Code example showing the anti-pattern
// Comment explaining the problemCorrect (description of what's right):
// Code example showing the correct approach
// Comment explaining the benefitWhen to use:
- Scenario 1
- Scenario 2
When NOT to use:
- Exception 1
- Exception 2
Reference: Documentation Title
/**
* Error Boundary Template
*
* A reusable error boundary component for catching and displaying errors.
*/
import { Component, ReactNode } from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
interface Props {
children: ReactNode;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
}
interface State {
hasError: boolean;
error?: Error;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
// Log error to your error reporting service
console.error('ErrorBoundary caught an error:', error, errorInfo);
// Call optional error handler
this.props.onError?.(error, errorInfo);
// Example: Log to Sentry
// Sentry.captureException(error, { extra: errorInfo });
}
handleRetry = () => {
this.setState({ hasError: false, error: undefined });
};
render() {
if (this.state.hasError) {
// Custom fallback provided
if (this.props.fallback) {
return this.props.fallback;
}
// Default fallback UI
return (
<View style={styles.container}>
<View style={styles.content}>
<Text style={styles.emoji}>😕</Text>
<Text style={styles.title}>Oops! Something went wrong</Text>
<Text style={styles.message}>
{this.state.error?.message || 'An unexpected error occurred'}
</Text>
<Pressable style={styles.button} onPress={this.handleRetry}>
<Text style={styles.buttonText}>Try Again</Text>
</Pressable>
</View>
</View>
);
}
return this.props.children;
}
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#f5f5f5',
padding: 20,
},
content: {
alignItems: 'center',
backgroundColor: '#fff',
padding: 32,
borderRadius: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 4,
maxWidth: 300,
},
emoji: {
fontSize: 48,
marginBottom: 16,
},
title: {
fontSize: 20,
fontWeight: '700',
color: '#333',
marginBottom: 8,
textAlign: 'center',
},
message: {
fontSize: 14,
color: '#666',
textAlign: 'center',
marginBottom: 24,
lineHeight: 20,
},
button: {
backgroundColor: '#007AFF',
paddingHorizontal: 32,
paddingVertical: 14,
borderRadius: 8,
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
});
export default ErrorBoundary;
/**
* Auth Context Template
*
* A complete authentication context with:
* - Session management
* - Secure token storage
* - Loading state handling
*/
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import * as SecureStore from 'expo-secure-store';
const SESSION_KEY = 'user-session';
interface User {
id: string;
email: string;
name: string;
}
interface AuthContextType {
user: User | null;
session: string | null;
isLoading: boolean;
signIn: (email: string, password: string) => Promise<void>;
signUp: (email: string, password: string, name: string) => Promise<void>;
signOut: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [session, setSession] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Load session on mount
useEffect(() => {
async function loadSession() {
try {
const token = await SecureStore.getItemAsync(SESSION_KEY);
if (token) {
// Validate token and get user
const response = await fetch('/api/auth/me', {
headers: { Authorization: `Bearer ${token}` },
});
if (response.ok) {
const userData = await response.json();
setUser(userData);
setSession(token);
} else {
// Token invalid, clear it
await SecureStore.deleteItemAsync(SESSION_KEY);
}
}
} catch (error) {
console.error('Failed to load session:', error);
} finally {
setIsLoading(false);
}
}
loadSession();
}, []);
const signIn = async (email: string, password: string) => {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Login failed');
}
const { token, user: userData } = await response.json();
await SecureStore.setItemAsync(SESSION_KEY, token);
setSession(token);
setUser(userData);
};
const signUp = async (email: string, password: string, name: string) => {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, name }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Registration failed');
}
const { token, user: userData } = await response.json();
await SecureStore.setItemAsync(SESSION_KEY, token);
setSession(token);
setUser(userData);
};
const signOut = async () => {
await SecureStore.deleteItemAsync(SESSION_KEY);
setSession(null);
setUser(null);
};
return (
<AuthContext.Provider
value={{
user,
session,
isLoading,
signIn,
signUp,
signOut,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
/**
* Auth Layout Template
*
* Root layout with protected routes based on authentication state.
* Place this file at: app/_layout.tsx
*/
import { Stack, SplashScreen } from 'expo-router';
import { useEffect } from 'react';
import { useFonts } from 'expo-font';
import { AuthProvider, useAuth } from '@/context/auth';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';
// Prevent splash from auto-hiding
SplashScreen.preventAutoHideAsync();
function RootLayoutNav() {
const { session, isLoading } = useAuth();
useEffect(() => {
if (!isLoading) {
SplashScreen.hideAsync();
}
}, [isLoading]);
if (isLoading) {
return null; // Splash screen remains visible
}
return (
<Stack screenOptions={{ headerShown: false }}>
{/* Protected app routes */}
<Stack.Protected guard={!!session}>
<Stack.Screen name="(app)" />
</Stack.Protected>
{/* Auth routes */}
<Stack.Protected guard={!session}>
<Stack.Screen name="sign-in" />
<Stack.Screen name="sign-up" />
<Stack.Screen name="forgot-password" />
</Stack.Protected>
{/* Modal screens (always accessible) */}
<Stack.Screen
name="modal"
options={{
presentation: 'modal',
headerShown: true,
}}
/>
</Stack>
);
}
export default function RootLayout() {
const [fontsLoaded, fontError] = useFonts({
// Add your custom fonts here
// 'Inter-Regular': require('@/assets/fonts/Inter-Regular.otf'),
});
useEffect(() => {
if (fontError) {
console.error('Font loading error:', fontError);
}
}, [fontError]);
if (!fontsLoaded && !fontError) {
return null;
}
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<AuthProvider>
<RootLayoutNav />
</AuthProvider>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}
/**
* Tab Layout Template
*
* Bottom tab navigator with icons and proper configuration.
* Place this file at: app/(tabs)/_layout.tsx
*/
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
type IconName = keyof typeof Ionicons.glyphMap;
interface TabConfig {
name: string;
title: string;
icon: IconName;
iconFocused: IconName;
}
const tabs: TabConfig[] = [
{ name: 'index', title: 'Home', icon: 'home-outline', iconFocused: 'home' },
{ name: 'search', title: 'Search', icon: 'search-outline', iconFocused: 'search' },
{ name: 'notifications', title: 'Alerts', icon: 'notifications-outline', iconFocused: 'notifications' },
{ name: 'profile', title: 'Profile', icon: 'person-outline', iconFocused: 'person' },
];
export default function TabLayout() {
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: '#007AFF',
tabBarInactiveTintColor: '#8E8E93',
headerShown: true,
tabBarStyle: {
borderTopWidth: 1,
borderTopColor: '#E5E5EA',
},
}}
>
{tabs.map((tab) => (
<Tabs.Screen
key={tab.name}
name={tab.name}
options={{
title: tab.title,
tabBarIcon: ({ color, size, focused }) => (
<Ionicons
name={focused ? tab.iconFocused : tab.icon}
size={size}
color={color}
/>
),
}}
/>
))}
</Tabs>
);
}
/**
* Detail Screen Template
*
* A detail screen that:
* - Fetches data based on route params
* - Shows loading and error states
* - Updates header dynamically
*/
import { useEffect, useState } from 'react';
import { View, Text, ScrollView, ActivityIndicator, StyleSheet } from 'react-native';
import { useLocalSearchParams, useNavigation } from 'expo-router';
import { SafeAreaView } from 'react-native-safe-area-context';
interface ItemDetail {
id: string;
title: string;
description: string;
createdAt: string;
author: {
name: string;
avatar: string;
};
}
export default function DetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const navigation = useNavigation();
const [item, setItem] = useState<ItemDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function fetchItem() {
try {
setError(null);
const response = await fetch(`/api/items/${id}`);
if (!response.ok) {
if (response.status === 404) {
throw new Error('Item not found');
}
throw new Error('Failed to load item');
}
const data = await response.json();
setItem(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
}
if (id) {
fetchItem();
}
}, [id]);
// Update header title when item loads
useEffect(() => {
if (item) {
navigation.setOptions({
title: item.title,
});
}
}, [item, navigation]);
if (loading) {
return (
<View style={styles.centered}>
<ActivityIndicator size="large" color="#007AFF" />
</View>
);
}
if (error) {
return (
<View style={styles.centered}>
<Text style={styles.errorText}>{error}</Text>
</View>
);
}
if (!item) {
return (
<View style={styles.centered}>
<Text style={styles.errorText}>Item not found</Text>
</View>
);
}
return (
<ScrollView style={styles.container}>
<View style={styles.header}>
<Text style={styles.title}>{item.title}</Text>
<View style={styles.meta}>
<Text style={styles.author}>By {item.author.name}</Text>
<Text style={styles.date}>
{new Date(item.createdAt).toLocaleDateString()}
</Text>
</View>
</View>
<View style={styles.content}>
<Text style={styles.description}>{item.description}</Text>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
centered: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
errorText: {
color: '#FF3B30',
fontSize: 16,
textAlign: 'center',
},
header: {
padding: 16,
borderBottomWidth: 1,
borderBottomColor: '#E5E5EA',
},
title: {
fontSize: 24,
fontWeight: 'bold',
color: '#000',
marginBottom: 12,
},
meta: {
flexDirection: 'row',
justifyContent: 'space-between',
},
author: {
fontSize: 14,
color: '#007AFF',
},
date: {
fontSize: 14,
color: '#8E8E93',
},
content: {
padding: 16,
},
description: {
fontSize: 16,
lineHeight: 24,
color: '#333',
},
});
/**
* Form Screen Template
*
* A complete form screen with:
* - Controlled inputs
* - Validation
* - Keyboard handling
* - Loading and error states
*/
import { useState } from 'react';
import { View, Text, TextInput, Pressable, ActivityIndicator, StyleSheet, Alert } from 'react-native';
import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
import { router } from 'expo-router';
import * as Haptics from 'expo-haptics';
interface FormData {
title: string;
description: string;
email: string;
}
interface FormErrors {
title?: string;
description?: string;
email?: string;
}
export default function FormScreen() {
const [form, setForm] = useState<FormData>({
title: '',
description: '',
email: '',
});
const [errors, setErrors] = useState<FormErrors>({});
const [touched, setTouched] = useState<Record<string, boolean>>({});
const [submitting, setSubmitting] = useState(false);
const validate = (field: keyof FormData, value: string): string | undefined => {
switch (field) {
case 'title':
if (!value.trim()) return 'Title is required';
if (value.length < 3) return 'Title must be at least 3 characters';
return undefined;
case 'description':
if (!value.trim()) return 'Description is required';
if (value.length < 10) return 'Description must be at least 10 characters';
return undefined;
case 'email':
if (!value.trim()) return 'Email is required';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return 'Invalid email address';
return undefined;
default:
return undefined;
}
};
const handleChange = (field: keyof FormData, value: string) => {
setForm(prev => ({ ...prev, [field]: value }));
if (touched[field]) {
setErrors(prev => ({ ...prev, [field]: validate(field, value) }));
}
};
const handleBlur = (field: keyof FormData) => {
setTouched(prev => ({ ...prev, [field]: true }));
setErrors(prev => ({ ...prev, [field]: validate(field, form[field]) }));
};
const validateAll = (): boolean => {
const newErrors: FormErrors = {
title: validate('title', form.title),
description: validate('description', form.description),
email: validate('email', form.email),
};
setErrors(newErrors);
setTouched({ title: true, description: true, email: true });
return !Object.values(newErrors).some(Boolean);
};
const handleSubmit = async () => {
if (!validateAll()) {
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
return;
}
setSubmitting(true);
try {
const response = await fetch('/api/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
});
if (!response.ok) {
throw new Error('Failed to submit');
}
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
router.back();
} catch (error) {
Alert.alert('Error', 'Failed to submit. Please try again.');
} finally {
setSubmitting(false);
}
};
return (
<KeyboardAwareScrollView
style={styles.container}
contentContainerStyle={styles.content}
bottomOffset={100}
>
<View style={styles.field}>
<Text style={styles.label}>Title *</Text>
<TextInput
style={[styles.input, errors.title && touched.title && styles.inputError]}
value={form.title}
onChangeText={(text) => handleChange('title', text)}
onBlur={() => handleBlur('title')}
placeholder="Enter title"
autoCapitalize="sentences"
/>
{errors.title && touched.title && (
<Text style={styles.error}>{errors.title}</Text>
)}
</View>
<View style={styles.field}>
<Text style={styles.label}>Email *</Text>
<TextInput
style={[styles.input, errors.email && touched.email && styles.inputError]}
value={form.email}
onChangeText={(text) => handleChange('email', text)}
onBlur={() => handleBlur('email')}
placeholder="Enter email"
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
/>
{errors.email && touched.email && (
<Text style={styles.error}>{errors.email}</Text>
)}
</View>
<View style={styles.field}>
<Text style={styles.label}>Description *</Text>
<TextInput
style={[styles.input, styles.textArea, errors.description && touched.description && styles.inputError]}
value={form.description}
onChangeText={(text) => handleChange('description', text)}
onBlur={() => handleBlur('description')}
placeholder="Enter description"
multiline
numberOfLines={4}
textAlignVertical="top"
/>
{errors.description && touched.description && (
<Text style={styles.error}>{errors.description}</Text>
)}
</View>
<Pressable
style={[styles.button, submitting && styles.buttonDisabled]}
onPress={handleSubmit}
disabled={submitting}
>
{submitting ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Submit</Text>
)}
</Pressable>
</KeyboardAwareScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
content: {
padding: 16,
},
field: {
marginBottom: 20,
},
label: {
fontSize: 14,
fontWeight: '600',
color: '#333',
marginBottom: 8,
},
input: {
borderWidth: 1,
borderColor: '#E5E5EA',
borderRadius: 8,
padding: 16,
fontSize: 16,
backgroundColor: '#FAFAFA',
},
inputError: {
borderColor: '#FF3B30',
},
textArea: {
minHeight: 120,
},
error: {
color: '#FF3B30',
fontSize: 12,
marginTop: 4,
},
button: {
backgroundColor: '#007AFF',
padding: 16,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
minHeight: 50,
marginTop: 20,
},
buttonDisabled: {
opacity: 0.6,
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
});
/**
* List Screen Template
*
* A complete list screen with:
* - FlashList for performance
* - Pull-to-refresh
* - Loading, error, and empty states
* - Navigation to detail screen
*/
import { useState, useCallback } from 'react';
import { View, Text, Pressable, RefreshControl, ActivityIndicator, StyleSheet } from 'react-native';
import { FlashList } from '@shopify/flash-list';
import { Link, useFocusEffect } from 'expo-router';
interface Item {
id: string;
title: string;
subtitle: string;
}
export default function ListScreen() {
const [items, setItems] = useState<Item[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchItems = useCallback(async () => {
try {
setError(null);
const response = await fetch('/api/items');
if (!response.ok) throw new Error('Failed to fetch items');
const data = await response.json();
setItems(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
// Fetch on screen focus
useFocusEffect(
useCallback(() => {
fetchItems();
}, [fetchItems])
);
const onRefresh = useCallback(() => {
setRefreshing(true);
fetchItems();
}, [fetchItems]);
// Loading state
if (loading) {
return (
<View style={styles.centered}>
<ActivityIndicator size="large" color="#007AFF" />
</View>
);
}
// Error state
if (error) {
return (
<View style={styles.centered}>
<Text style={styles.errorText}>{error}</Text>
<Pressable style={styles.retryButton} onPress={fetchItems}>
<Text style={styles.retryText}>Retry</Text>
</Pressable>
</View>
);
}
// Empty state
if (items.length === 0) {
return (
<View style={styles.centered}>
<Text style={styles.emptyText}>No items yet</Text>
<Text style={styles.emptySubtext}>Pull down to refresh</Text>
</View>
);
}
return (
<FlashList
data={items}
estimatedItemSize={80}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<Link href={`/items/${item.id}`} asChild>
<Pressable style={styles.itemRow}>
<View style={styles.itemContent}>
<Text style={styles.itemTitle}>{item.title}</Text>
<Text style={styles.itemSubtitle}>{item.subtitle}</Text>
</View>
<Text style={styles.chevron}>›</Text>
</Pressable>
</Link>
)}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor="#007AFF"
/>
}
ItemSeparatorComponent={() => <View style={styles.separator} />}
/>
);
}
const styles = StyleSheet.create({
centered: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 20,
},
errorText: {
color: '#FF3B30',
fontSize: 16,
marginBottom: 16,
textAlign: 'center',
},
retryButton: {
backgroundColor: '#007AFF',
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 8,
},
retryText: {
color: '#fff',
fontWeight: '600',
},
emptyText: {
fontSize: 18,
fontWeight: '600',
color: '#333',
marginBottom: 8,
},
emptySubtext: {
fontSize: 14,
color: '#8E8E93',
},
itemRow: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
backgroundColor: '#fff',
},
itemContent: {
flex: 1,
},
itemTitle: {
fontSize: 16,
fontWeight: '600',
color: '#000',
marginBottom: 4,
},
itemSubtitle: {
fontSize: 14,
color: '#8E8E93',
},
chevron: {
fontSize: 20,
color: '#C7C7CC',
},
separator: {
height: 1,
backgroundColor: '#E5E5EA',
marginLeft: 16,
},
});
{
"version": "1.0.5",
"organization": "Expo React Native Coder",
"technology": "Expo React Native",
"date": "February 2026",
"abstract": "Comprehensive feature development guide for Expo React Native applications, designed for AI agents and LLMs. Contains 50+ rules across 10 categories covering project setup, routing, screens, data fetching, authentication, deep linking, native UX, forms, assets, and testing. Includes production-ready templates for common features like tab layouts, authentication flows, list screens, and form handling.",
"references": [
"https://docs.expo.dev",
"https://docs.expo.dev/router/introduction/",
"https://docs.expo.dev/develop/authentication/",
"https://docs.expo.dev/linking/overview/",
"https://reactnative.dev/docs/getting-started",
"https://react.dev"
]
}
Expo React Native Coder
Comprehensive feature development guide for Expo React Native applications, designed for AI agents and LLMs. Contains 50 rules across 10 categories with production-ready code templates.
Overview
This skill provides best practices for building Expo React Native apps, covering:
- Project Setup: TypeScript, environment variables, EAS configuration
- Routing: Expo Router file-based navigation, tabs, stacks, drawers
- Screens: List, detail, form, and settings screen patterns
- Data: API routes, local storage, state management
- Authentication: Protected routes, OAuth, session management
- Deep Linking: Custom schemes, Universal Links, App Links
- Native UX: Safe areas, haptics, gestures, keyboard handling
- Forms: Input configuration, validation, submission
- Assets: Images, fonts, icons, splash screens
- Testing: Jest unit tests, Maestro E2E, error boundaries
Structure
expo-react-native-coder/
├── SKILL.md # Quick reference entry point
├── AGENTS.md # Complete compiled guide
├── README.md # This file
├── metadata.json # Version and references
├── references/
│ ├── _sections.md # Category definitions
│ ├── setup-*.md # Project setup rules
│ ├── route-*.md # Routing rules
│ ├── screen-*.md # Screen pattern rules
│ ├── data-*.md # Data fetching rules
│ ├── auth-*.md # Authentication rules
│ ├── link-*.md # Deep linking rules
│ ├── ux-*.md # Native UX rules
│ ├── form-*.md # Form rules
│ ├── asset-*.md # Asset rules
│ └── test-*.md # Testing rules
└── assets/
└── templates/
├── layouts/ # Layout templates
├── screens/ # Screen templates
├── components/ # Component templates
├── hooks/ # Hook templates
└── _template.md # Rule templateGetting Started
1. Install the skill in your Claude Code or AI agent environment 2. Reference SKILL.md for quick lookups by category 3. Read AGENTS.md for comprehensive guidance 4. Copy templates from assets/templates/ as starting points
Commands
# Validate skill structure
pnpm validate
# Build AGENTS.md from references
pnpm build
# Install dependencies (for validation)
pnpm installCreating a New Rule
1. Choose the appropriate category prefix from the table below 2. Create a file in references/ with the format {prefix}-{description}.md 3. Follow the template in assets/templates/_template.md 4. Run pnpm build to regenerate AGENTS.md
Prefix Reference
| Category | Prefix | Impact |
|---|---|---|
| Project Setup | setup- | CRITICAL |
| Routing & Navigation | route- | CRITICAL |
| Screen Patterns | screen- | HIGH |
| Data Fetching | data- | HIGH |
| Authentication | auth- | HIGH |
| Deep Linking | link- | HIGH |
| Native UX | ux- | MEDIUM-HIGH |
| Forms | form- | MEDIUM |
| Assets | asset- | MEDIUM |
| Testing | test- | MEDIUM |
Rule File Structure
Each rule file should contain:
---
title: Rule Title
impact: CRITICAL|HIGH|MEDIUM-HIGH|MEDIUM|LOW
impactDescription: Brief impact description
tags: prefix, keyword1, keyword2
---
## Rule Title
Brief explanation of WHY this matters.
**Incorrect (what's wrong):**
\`\`\`typescript
// Bad example
\`\`\`
**Correct (what's right):**
\`\`\`typescript
// Good example
\`\`\`
Reference: [Docs](https://docs.expo.dev/...)File Naming Convention
- Use the category prefix followed by a descriptive kebab-case name
- Examples:
route-tab-navigator.md,auth-protected-routes.md - The first tag in YAML frontmatter must match the prefix
Impact Levels
| Level | Description |
|---|---|
| CRITICAL | Foundation patterns that affect entire app |
| HIGH | Important patterns for common features |
| MEDIUM-HIGH | Patterns that improve UX significantly |
| MEDIUM | Good practices with moderate impact |
| LOW | Nice-to-have optimizations |
Scripts
| Script | Description |
|---|---|
pnpm validate | Validates skill structure and rules |
pnpm build | Regenerates AGENTS.md from references |
Contributing
1. Follow the existing rule format 2. Include Incorrect and Correct code examples 3. Reference official Expo/React Native documentation 4. Use production-realistic code, not strawman examples 5. Quantify impact where possible
Acknowledgments
- Expo Documentation - Official Expo guides
- React Native Documentation - Official React Native docs
- Callstack Agent Skills - React Native best practices
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Project Setup & Configuration (setup)
Impact: CRITICAL Description: TypeScript configuration, environment variables, app.json/app.config.ts, and EAS setup form the foundation for all features and must be correct from the start.
2. Routing & Navigation (route)
Impact: CRITICAL Description: Expo Router file-based routing is the backbone of app navigation. Proper stack, tab, and drawer configuration determines the entire user flow.
3. Screen Patterns & Layouts (screen)
Impact: HIGH Description: Consistent screen patterns (list, detail, form, settings) establish predictable UX and reduce development time through reusable templates.
4. Data Fetching & State (data)
Impact: HIGH Description: API calls, server functions, caching strategies, and local storage patterns determine app responsiveness and offline capability.
5. Authentication & Security (auth)
Impact: HIGH Description: Protected routes, session management, secure token storage, and OAuth flows are critical for user data protection.
6. Deep Linking & Universal Links (link)
Impact: HIGH Description: Custom URL schemes, iOS Universal Links, and Android App Links enable seamless app-to-app and web-to-app transitions.
7. Native UX Patterns (ux)
Impact: MEDIUM-HIGH Description: Safe areas, status bar styling, haptic feedback, and gesture handling create the native feel users expect on mobile.
8. Forms & User Input (form)
Impact: MEDIUM Description: TextInput handling, keyboard avoiding views, form validation, and multi-step forms are essential for data collection screens.
9. Assets & Theming (asset)
Impact: MEDIUM Description: Image optimization, font loading, icon systems, dark mode support, and splash screens define the visual identity.
10. Error Handling & Testing (test)
Impact: MEDIUM Description: Error boundaries, Jest unit tests, Maestro E2E tests, and crash reporting ensure app reliability in production.
Load Custom Fonts with useFonts Hook
Use the useFonts hook from expo-font to load custom fonts before rendering text.
Incorrect (using fonts before loaded):
// Font may not be loaded, causing fallback or error
<Text style={{ fontFamily: 'MyCustomFont' }}>Hello</Text>Correct (load fonts before rendering):
import { useFonts } from 'expo-font';
import { SplashScreen, Stack } from 'expo-router';
import { useEffect } from 'react';
// Prevent splash from auto-hiding
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const [fontsLoaded, fontError] = useFonts({
'Inter-Regular': require('@/assets/fonts/Inter-Regular.otf'),
'Inter-Bold': require('@/assets/fonts/Inter-Bold.otf'),
'Inter-Medium': require('@/assets/fonts/Inter-Medium.otf'),
});
useEffect(() => {
if (fontsLoaded || fontError) {
SplashScreen.hideAsync();
}
}, [fontsLoaded, fontError]);
if (!fontsLoaded && !fontError) {
return null; // Splash screen stays visible
}
return <Stack />;
}// Using loaded fonts in components
import { Text, StyleSheet } from 'react-native';
export function Heading({ children }: { children: string }) {
return <Text style={styles.heading}>{children}</Text>;
}
const styles = StyleSheet.create({
heading: {
fontFamily: 'Inter-Bold',
fontSize: 24,
},
});Alternative: Config plugin (static fonts):
// app.json - embeds fonts at build time (faster startup)
{
"expo": {
"plugins": [
[
"expo-font",
{
"fonts": ["./assets/fonts/Inter-Regular.otf"]
}
]
]
}
}Note: OTF is preferred over TTF (smaller size, better rendering).
Reference: Fonts - Expo Documentation
Use expo-image for Optimized Image Loading
Use expo-image instead of React Native's Image for better caching, performance, and format support (including WebP and AVIF).
Incorrect (basic Image component):
import { Image } from 'react-native';
<Image
source={{ uri: 'https://example.com/photo.jpg' }}
style={{ width: 200, height: 200 }}
/>
// No caching strategy, no placeholder, no loading stateCorrect (expo-image with caching):
npx expo install expo-imageimport { Image } from 'expo-image';
import { StyleSheet, View } from 'react-native';
const blurhash = '|rF?hV%2WCj[ayj[a|j[az_NaeWBj@ayfRayfQfQM{M|azj[azf6fQfQfQIpWXofj[ayj[j[fQayWCoeoeaya}j[ayfQa{oLj?j[WVj[ayayj[fQoff7teleayj[ayj[ayofayayayj[fQj[ayayj[ayfjj[j[ayjuayj[';
export default function ProfileImage({ uri }: { uri: string }) {
return (
<Image
source={uri}
placeholder={{ blurhash }}
contentFit="cover"
transition={200}
style={styles.image}
cachePolicy="memory-disk" // Cache in memory and disk
/>
);
}
// For local images
function LocalImage() {
return (
<Image
source={require('@/assets/images/logo.png')}
style={styles.logo}
contentFit="contain"
/>
);
}
const styles = StyleSheet.create({
image: { width: 100, height: 100, borderRadius: 50 },
logo: { width: 200, height: 80 },
});Key features:
placeholder: Blurhash or low-res image while loadingtransition: Fade-in animation duration (ms)contentFit:cover,contain,fill,none,scale-downcachePolicy:memory,disk,memory-disk,none
Reference: expo-image - Expo Documentation
Configure Splash Screen with app.json
Configure the splash screen in app.json with your app icon, background color, and resize mode.
Incorrect (default/missing splash configuration):
{
"expo": {
"name": "MyApp"
}
}
// Shows default Expo splash screenCorrect (custom splash screen):
{
"expo": {
"name": "MyApp",
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"ios": {
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff",
"dark": {
"image": "./assets/splash-icon-dark.png",
"backgroundColor": "#000000"
}
}
},
"android": {
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff",
"dark": {
"image": "./assets/splash-icon-dark.png",
"backgroundColor": "#000000"
}
}
}
}
}Controlling splash visibility:
import { SplashScreen } from 'expo-router';
import { useEffect } from 'react';
// Prevent auto-hide
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const [appIsReady, setAppIsReady] = useState(false);
useEffect(() => {
async function prepare() {
// Load fonts, fetch initial data, etc.
await loadFonts();
await fetchInitialData();
setAppIsReady(true);
}
prepare();
}, []);
useEffect(() => {
if (appIsReady) {
SplashScreen.hideAsync();
}
}, [appIsReady]);
if (!appIsReady) return null;
return <Stack />;
}Requirements:
- Image: PNG format, 1024x1024 recommended
- Test on preview/production builds (not Expo Go)
Reference: Splash screen - Expo Documentation
Use @expo/vector-icons for Icon Sets
Use @expo/vector-icons for popular icon sets like Ionicons, MaterialIcons, and FontAwesome. It's included in Expo by default.
Incorrect (custom SVG icons for common symbols):
// Creating custom SVGs for standard icons
import HomeSvg from '@/assets/icons/home.svg';
import SearchSvg from '@/assets/icons/search.svg';
// Unnecessary work for common iconsCorrect (vector icons library):
import { Ionicons, MaterialIcons, FontAwesome } from '@expo/vector-icons';
import { View } from 'react-native';
export default function IconExamples() {
return (
<View>
{/* Ionicons - iOS-style icons */}
<Ionicons name="home" size={24} color="#333" />
<Ionicons name="search" size={24} color="#333" />
<Ionicons name="person" size={24} color="#333" />
{/* MaterialIcons - Material Design icons */}
<MaterialIcons name="settings" size={24} color="#333" />
<MaterialIcons name="delete" size={24} color="#333" />
{/* FontAwesome */}
<FontAwesome name="heart" size={24} color="red" />
</View>
);
}
// In tab navigator
import { Tabs } from 'expo-router';
export default function TabLayout() {
return (
<Tabs>
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarIcon: ({ color, size }) => (
<Ionicons name="home" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
tabBarIcon: ({ color, size }) => (
<Ionicons name="person" size={size} color={color} />
),
}}
/>
</Tabs>
);
}Browse all icons: icons.expo.fyi
Reference: Icons - Expo Documentation
Create Auth Context with Session Management
Create an AuthContext that manages session state, handles sign in/out, and persists tokens with SecureStore.
Incorrect (auth state scattered across components):
// Each component manages its own auth state
const [token, setToken] = useState(null);
// Inconsistent state across appCorrect (centralized auth context):
// context/auth.tsx
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import * as SecureStore from 'expo-secure-store';
interface AuthContextType {
session: string | null;
isLoading: boolean;
signIn: (token: string) => Promise<void>;
signOut: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Load token on mount
useEffect(() => {
async function loadSession() {
try {
const token = await SecureStore.getItemAsync('session');
setSession(token);
} finally {
setIsLoading(false);
}
}
loadSession();
}, []);
const signIn = async (token: string) => {
await SecureStore.setItemAsync('session', token);
setSession(token);
};
const signOut = async () => {
await SecureStore.deleteItemAsync('session');
setSession(null);
};
return (
<AuthContext.Provider value={{ session, isLoading, signIn, signOut }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}// app/_layout.tsx - Wrap app with provider
import { AuthProvider } from '@/context/auth';
export default function RootLayout() {
return (
<AuthProvider>
<Stack>...</Stack>
</AuthProvider>
);
}Reference: Authentication in Expo Router - Expo Documentation
Build Login Form with Validation
Create a login form with input validation, error handling, and loading states for a polished authentication experience.
Incorrect (no validation or error handling):
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleLogin = () => {
fetch('/api/login', { body: JSON.stringify({ email, password }) });
};Correct (complete login form):
import { useState } from 'react';
import { View, TextInput, Text, Pressable, ActivityIndicator, StyleSheet } from 'react-native';
import { useAuth } from '@/context/auth';
import { router } from 'expo-router';
export default function SignInScreen() {
const { signIn } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const validateEmail = (email: string) => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
};
const handleLogin = async () => {
setError(null);
// Validation
if (!email.trim()) {
setError('Email is required');
return;
}
if (!validateEmail(email)) {
setError('Please enter a valid email');
return;
}
if (!password || password.length < 6) {
setError('Password must be at least 6 characters');
return;
}
setLoading(true);
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.message || 'Login failed');
}
const { token } = await response.json();
await signIn(token);
// Navigation handled by protected routes
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed');
} finally {
setLoading(false);
}
};
return (
<View style={styles.container}>
<Text style={styles.title}>Sign In</Text>
{error && <Text style={styles.error}>{error}</Text>}
<TextInput
style={styles.input}
placeholder="Email"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
autoComplete="email"
/>
<TextInput
style={styles.input}
placeholder="Password"
value={password}
onChangeText={setPassword}
secureTextEntry
autoComplete="password"
/>
<Pressable
style={[styles.button, loading && styles.buttonDisabled]}
onPress={handleLogin}
disabled={loading}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Sign In</Text>
)}
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 24, justifyContent: 'center' },
title: { fontSize: 32, fontWeight: 'bold', marginBottom: 24 },
input: { borderWidth: 1, borderColor: '#ddd', padding: 16, borderRadius: 8, marginBottom: 16 },
button: { backgroundColor: '#007AFF', padding: 16, borderRadius: 8, alignItems: 'center' },
buttonDisabled: { opacity: 0.6 },
buttonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
error: { color: 'red', marginBottom: 16 },
});Reference: Authentication - Expo Documentation
Implement OAuth with AuthSession
Use expo-auth-session for OAuth flows with providers like Google, Apple, or custom OAuth servers.
Incorrect (manual WebView for OAuth):
// Opening OAuth in WebView - won't redirect back properly
<WebView source={{ uri: oauthUrl }} />Correct (AuthSession for OAuth):
import * as AuthSession from 'expo-auth-session';
import * as WebBrowser from 'expo-web-browser';
import { Button } from 'react-native';
// Required for web browser redirect
WebBrowser.maybeCompleteAuthSession();
// OAuth configuration
const discovery = {
authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
tokenEndpoint: 'https://oauth2.googleapis.com/token',
revocationEndpoint: 'https://oauth2.googleapis.com/revoke',
};
export default function SignInScreen() {
const [request, response, promptAsync] = AuthSession.useAuthRequest(
{
clientId: 'YOUR_GOOGLE_CLIENT_ID',
scopes: ['openid', 'profile', 'email'],
redirectUri: AuthSession.makeRedirectUri({
scheme: 'myapp',
path: 'auth',
}),
},
discovery
);
useEffect(() => {
if (response?.type === 'success') {
const { code } = response.params;
// Exchange code for token on your server
exchangeCodeForToken(code);
}
}, [response]);
const exchangeCodeForToken = async (code: string) => {
const response = await fetch('/api/auth/exchange', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
});
const { token } = await response.json();
// Store token and update auth state
};
return (
<Button
title="Sign in with Google"
disabled={!request}
onPress={() => promptAsync()}
/>
);
}Note: For production, use provider-specific libraries (e.g., @react-native-google-signin/google-signin) for better UX.
Reference: AuthSession - Expo Documentation
Use Stack.Protected for Authentication Guards
Use Stack.Protected to guard routes based on authentication state. Unauthenticated users are automatically redirected.
Incorrect (manual redirect logic in each screen):
// Every protected screen needs this check
export default function ProfileScreen() {
const { session } = useAuth();
const router = useRouter();
useEffect(() => {
if (!session) router.replace('/sign-in');
}, [session]);
if (!session) return null;
return <View>...</View>;
}Correct (centralized protection with Stack.Protected):
// app/_layout.tsx
import { Stack } from 'expo-router';
import { useAuth } from '@/context/auth';
export default function RootLayout() {
const { session, isLoading } = useAuth();
if (isLoading) {
return <SplashScreen />;
}
return (
<Stack>
{/* Protected routes - only accessible when logged in */}
<Stack.Protected guard={!!session}>
<Stack.Screen name="(app)" options={{ headerShown: false }} />
</Stack.Protected>
{/* Auth routes - only accessible when logged out */}
<Stack.Protected guard={!session}>
<Stack.Screen name="sign-in" options={{ headerShown: false }} />
<Stack.Screen name="sign-up" options={{ headerShown: false }} />
</Stack.Protected>
</Stack>
);
}app/
├── _layout.tsx # Root layout with Protected guards
├── sign-in.tsx # Shown when !session
├── sign-up.tsx # Shown when !session
└── (app)/ # Protected group
├── _layout.tsx # App layout (tabs, etc.)
├── index.tsx # Home screen
└── profile.tsx # Profile screenNote: When session changes, the layout re-renders and navigation updates automatically.
Reference: Authentication in Expo Router - Expo Documentation
Show Splash Screen During Auth Loading
Keep the splash screen visible while checking authentication state to prevent flashing the wrong screen.
Incorrect (flash of login screen before redirect):
export default function RootLayout() {
const { session, isLoading } = useAuth();
// Shows login briefly before session loads
return (
<Stack>
<Stack.Protected guard={!!session}>
<Stack.Screen name="(app)" />
</Stack.Protected>
</Stack>
);
}Correct (splash screen during auth check):
import { Stack, SplashScreen } from 'expo-router';
import { useEffect } from 'react';
import { useAuth } from '@/context/auth';
// Prevent auto-hide
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const { session, isLoading } = useAuth();
useEffect(() => {
if (!isLoading) {
// Hide splash once auth state is determined
SplashScreen.hideAsync();
}
}, [isLoading]);
// Show nothing while loading (splash screen remains visible)
if (isLoading) {
return null;
}
return (
<Stack>
<Stack.Protected guard={!!session}>
<Stack.Screen name="(app)" options={{ headerShown: false }} />
</Stack.Protected>
<Stack.Protected guard={!session}>
<Stack.Screen name="sign-in" options={{ headerShown: false }} />
</Stack.Protected>
</Stack>
);
}Alternative with custom loading screen:
if (isLoading) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" />
</View>
);
}Reference: SplashScreen - Expo Documentation
Cancel Fetch Requests on Unmount with AbortController
Cancel ongoing fetch requests when a component unmounts to prevent memory leaks and "state update on unmounted component" warnings.
Incorrect (no cleanup, causes memory leaks):
useEffect(() => {
fetch('/api/posts')
.then(r => r.json())
.then(setPosts); // May run after unmount!
}, []);Correct (AbortController for cleanup):
import { useEffect, useState } from 'react';
interface Post {
id: string;
title: string;
}
export default function PostsScreen() {
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const abortController = new AbortController();
async function fetchPosts() {
try {
const response = await fetch('/api/posts', {
signal: abortController.signal,
});
const data = await response.json();
setPosts(data);
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
// Request was cancelled, ignore
return;
}
setError(err instanceof Error ? err.message : 'Failed to fetch');
} finally {
setLoading(false);
}
}
fetchPosts();
return () => {
abortController.abort(); // Cancel on unmount
};
}, []);
return (
<View>
{loading && <ActivityIndicator />}
{error && <Text>{error}</Text>}
{posts.map(post => <Text key={post.id}>{post.title}</Text>)}
</View>
);
}Alternative with flag pattern:
useEffect(() => {
let isMounted = true;
fetch('/api/posts')
.then(r => r.json())
.then(data => {
if (isMounted) setPosts(data);
});
return () => { isMounted = false; };
}, []);Reference: Memory Management - React Native
Use API Routes for Server-Side Data Fetching
Expo Router API routes run on the server, allowing secure access to secrets and databases. Create them with the +api.ts suffix.
Incorrect (exposing API keys in client code):
// Client-side - API key exposed in bundle
const response = await fetch('https://api.stripe.com/charges', {
headers: {
'Authorization': `Bearer ${process.env.EXPO_PUBLIC_STRIPE_KEY}`, // EXPOSED!
},
});Correct (API route keeps secrets server-side):
// app/api/create-charge+api.ts
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); // Server-only
export async function POST(request: Request) {
const body = await request.json();
const { amount, currency } = body;
try {
const charge = await stripe.charges.create({
amount,
currency,
});
return Response.json({ success: true, chargeId: charge.id });
} catch (error) {
return Response.json(
{ error: 'Payment failed' },
{ status: 400 }
);
}
}// Client component - calls API route
export default function PaymentScreen() {
const handlePayment = async () => {
const response = await fetch('/api/create-charge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: 1000, currency: 'usd' }),
});
const result = await response.json();
if (result.success) {
// Handle success
}
};
return <Button title="Pay $10" onPress={handlePayment} />;
}Note: API routes require a deployed server (EAS Hosting or custom server) for production.
Reference: API Routes - Expo Documentation
Use AsyncStorage for Non-Sensitive Preferences
Use AsyncStorage for non-sensitive data like user preferences, onboarding state, or cached non-critical data.
Incorrect (using SecureStore for preferences - overkill):
// SecureStore is slower and has size limits
await SecureStore.setItemAsync('theme', 'dark');
await SecureStore.setItemAsync('hasSeenOnboarding', 'true');Correct (AsyncStorage for preferences):
import AsyncStorage from '@react-native-async-storage/async-storage';
// Simple key-value storage
const STORAGE_KEYS = {
THEME: '@app/theme',
HAS_SEEN_ONBOARDING: '@app/hasSeenOnboarding',
PREFERRED_LANGUAGE: '@app/language',
} as const;
// Store preference
async function setTheme(theme: 'light' | 'dark') {
await AsyncStorage.setItem(STORAGE_KEYS.THEME, theme);
}
// Get preference with default
async function getTheme(): Promise<'light' | 'dark'> {
const theme = await AsyncStorage.getItem(STORAGE_KEYS.THEME);
return (theme as 'light' | 'dark') || 'light';
}
// Store complex object (must serialize)
async function saveUserPreferences(prefs: UserPreferences) {
await AsyncStorage.setItem('@app/preferences', JSON.stringify(prefs));
}
// Get complex object
async function getUserPreferences(): Promise<UserPreferences | null> {
const json = await AsyncStorage.getItem('@app/preferences');
return json ? JSON.parse(json) : null;
}
// Usage in hook
export function useTheme() {
const [theme, setThemeState] = useState<'light' | 'dark'>('light');
useEffect(() => {
getTheme().then(setThemeState);
}, []);
const toggleTheme = async () => {
const newTheme = theme === 'light' ? 'dark' : 'light';
await setTheme(newTheme);
setThemeState(newTheme);
};
return { theme, toggleTheme };
}Note: Use @ prefix for keys to namespace and avoid conflicts.
Reference: AsyncStorage - Expo Documentation
Refetch Data When Screen Comes into Focus
Use useFocusEffect to refetch data when a screen regains focus, ensuring users see fresh data after navigating away and back.
Incorrect (data only fetched on mount):
useEffect(() => {
fetchPosts();
}, []);
// Data becomes stale if user creates post on another screenCorrect (refetch on screen focus):
import { useFocusEffect } from 'expo-router';
import { useCallback, useState } from 'react';
interface Post {
id: string;
title: string;
}
export default function FeedScreen() {
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const fetchPosts = useCallback(async () => {
setLoading(true);
try {
const response = await fetch('/api/posts');
const data = await response.json();
setPosts(data);
} finally {
setLoading(false);
}
}, []);
useFocusEffect(
useCallback(() => {
fetchPosts();
}, [fetchPosts])
);
return (
<FlashList
data={posts}
renderItem={({ item }) => <Text>{item.title}</Text>}
estimatedItemSize={50}
refreshing={loading}
/>
);
}When to use:
- List screens where items can be created/edited on other screens
- Profile screens that might be updated elsewhere
- Any screen with data that changes frequently
When NOT to use:
- Static content that rarely changes
- Screens where refetching would disrupt user interaction
Reference: useFocusEffect - React Navigation
Store Sensitive Data with SecureStore
Use expo-secure-store for sensitive data like auth tokens. It encrypts data using the device's secure enclave (iOS) or Keystore (Android).
Incorrect (storing tokens in AsyncStorage):
import AsyncStorage from '@react-native-async-storage/async-storage';
// AsyncStorage is NOT encrypted - tokens can be extracted
await AsyncStorage.setItem('authToken', token);Correct (SecureStore for sensitive data):
import * as SecureStore from 'expo-secure-store';
// Store token securely
async function saveToken(token: string) {
await SecureStore.setItemAsync('authToken', token);
}
// Retrieve token
async function getToken(): Promise<string | null> {
return await SecureStore.getItemAsync('authToken');
}
// Delete token (on logout)
async function deleteToken() {
await SecureStore.deleteItemAsync('authToken');
}
// Usage in auth context
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
async function loadToken() {
const storedToken = await getToken();
setToken(storedToken);
setIsLoading(false);
}
loadToken();
}, []);
const signIn = async (newToken: string) => {
await saveToken(newToken);
setToken(newToken);
};
const signOut = async () => {
await deleteToken();
setToken(null);
};
return (
<AuthContext.Provider value={{ token, isLoading, signIn, signOut }}>
{children}
</AuthContext.Provider>
);
}Limitations: 2KB value size limit per key. For larger data, use encryption + AsyncStorage.
Reference: SecureStore - Expo Documentation
Use SQLite for Complex Local Data
Use expo-sqlite for complex data that needs queries, relationships, or offline support. It persists across app restarts.
Incorrect (AsyncStorage for relational data):
// Storing complex data as JSON strings - no queries possible
await AsyncStorage.setItem('users', JSON.stringify(users));
await AsyncStorage.setItem('posts', JSON.stringify(posts));
// Can't query "posts by user" without loading everythingCorrect (SQLite with proper schema):
import * as SQLite from 'expo-sqlite';
// Open database (creates if doesn't exist)
const db = await SQLite.openDatabaseAsync('myapp.db');
// Create tables
await db.execAsync(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE
);
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
title TEXT NOT NULL,
body TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
`);
// Insert data
async function createPost(userId: number, title: string, body: string) {
const result = await db.runAsync(
'INSERT INTO posts (user_id, title, body) VALUES (?, ?, ?)',
[userId, title, body]
);
return result.lastInsertRowId;
}
// Query data
async function getPostsByUser(userId: number) {
return await db.getAllAsync<{ id: number; title: string; body: string }>(
'SELECT id, title, body FROM posts WHERE user_id = ? ORDER BY created_at DESC',
[userId]
);
}
// Use in component
export default function UserPostsScreen() {
const { userId } = useLocalSearchParams<{ userId: string }>();
const [posts, setPosts] = useState<Post[]>([]);
useEffect(() => {
getPostsByUser(Number(userId)).then(setPosts);
}, [userId]);
return <FlashList data={posts} ... />;
}Alternative: Use expo-sqlite/kv-store as a drop-in AsyncStorage replacement with synchronous APIs.
Reference: SQLite - Expo Documentation
Use Controlled Inputs with useState
Use controlled inputs where React state is the source of truth. This enables real-time validation and form management.
Incorrect (uncontrolled inputs):
// Can't validate or manage state
<TextInput ref={inputRef} />
const value = inputRef.current?.value; // Doesn't work in RNCorrect (controlled inputs):
import { useState } from 'react';
import { View, TextInput, Text, Pressable, StyleSheet } from 'react-native';
interface FormData {
username: string;
email: string;
}
interface FormErrors {
username?: string;
email?: string;
}
export default function ProfileForm() {
const [form, setForm] = useState<FormData>({ username: '', email: '' });
const [errors, setErrors] = useState<FormErrors>({});
const [touched, setTouched] = useState<Record<string, boolean>>({});
const validate = (field: keyof FormData, value: string): string | undefined => {
switch (field) {
case 'username':
if (!value.trim()) return 'Username is required';
if (value.length < 3) return 'Username must be at least 3 characters';
return undefined;
case 'email':
if (!value.trim()) return 'Email is required';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return 'Invalid email';
return undefined;
}
};
const handleChange = (field: keyof FormData, value: string) => {
setForm(prev => ({ ...prev, [field]: value }));
if (touched[field]) {
setErrors(prev => ({ ...prev, [field]: validate(field, value) }));
}
};
const handleBlur = (field: keyof FormData) => {
setTouched(prev => ({ ...prev, [field]: true }));
setErrors(prev => ({ ...prev, [field]: validate(field, form[field]) }));
};
const handleSubmit = () => {
const newErrors: FormErrors = {
username: validate('username', form.username),
email: validate('email', form.email),
};
setErrors(newErrors);
setTouched({ username: true, email: true });
if (!newErrors.username && !newErrors.email) {
// Submit form
console.log('Submitting:', form);
}
};
return (
<View style={styles.container}>
<TextInput
placeholder="Username"
value={form.username}
onChangeText={(text) => handleChange('username', text)}
onBlur={() => handleBlur('username')}
style={[styles.input, errors.username && styles.inputError]}
/>
{errors.username && <Text style={styles.error}>{errors.username}</Text>}
<TextInput
placeholder="Email"
value={form.email}
onChangeText={(text) => handleChange('email', text)}
onBlur={() => handleBlur('email')}
keyboardType="email-address"
style={[styles.input, errors.email && styles.inputError]}
/>
{errors.email && <Text style={styles.error}>{errors.email}</Text>}
<Pressable style={styles.button} onPress={handleSubmit}>
<Text style={styles.buttonText}>Save</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: { padding: 16 },
input: { borderWidth: 1, borderColor: '#ddd', padding: 16, borderRadius: 8, marginBottom: 4 },
inputError: { borderColor: 'red' },
error: { color: 'red', fontSize: 12, marginBottom: 12 },
button: { backgroundColor: '#007AFF', padding: 16, borderRadius: 8, alignItems: 'center' },
buttonText: { color: '#fff', fontWeight: '600' },
});Reference: TextInput - React Native
Dismiss Keyboard on Tap Outside Input
Allow users to dismiss the keyboard by tapping outside of text inputs using Keyboard.dismiss() or ScrollView with keyboardShouldPersistTaps.
Incorrect (keyboard stays open until submit):
<View>
<TextInput placeholder="Search..." />
<FlatList data={results} />
</View>
// Can't dismiss keyboard by tapping outsideCorrect (dismissible keyboard):
import { Keyboard, Pressable, TextInput, View, StyleSheet } from 'react-native';
// Option 1: Wrap in Pressable that dismisses keyboard
export function SearchScreen() {
return (
<Pressable style={{ flex: 1 }} onPress={Keyboard.dismiss}>
<TextInput placeholder="Search..." style={styles.input} />
<FlatList data={results} renderItem={...} />
</Pressable>
);
}
// Option 2: ScrollView with keyboardShouldPersistTaps
export function FormScreen() {
return (
<ScrollView
keyboardShouldPersistTaps="handled"
contentContainerStyle={styles.container}
>
<TextInput placeholder="Name" style={styles.input} />
<TextInput placeholder="Email" style={styles.input} />
<Pressable onPress={handleSubmit}>
<Text>Submit</Text>
</Pressable>
</ScrollView>
);
}
// Option 3: Dismiss on specific action
export function ChatInput() {
const handleSend = () => {
sendMessage();
Keyboard.dismiss();
};
return (
<View style={styles.inputRow}>
<TextInput placeholder="Message..." />
<Pressable onPress={handleSend}>
<Text>Send</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: { padding: 16 },
input: { borderWidth: 1, borderColor: '#ddd', padding: 16, borderRadius: 8, marginBottom: 16 },
inputRow: { flexDirection: 'row', padding: 8 },
});keyboardShouldPersistTaps options:
'never': Tapping outside dismisses keyboard'always': Keyboard never auto-dismisses'handled': Dismisses unless tap is on a Touchable
Reference: Keyboard - React Native
Disable Submit Button During Form Submission
Disable the submit button and show a loading indicator during submission to prevent duplicate requests.
Incorrect (button remains active during submission):
const handleSubmit = async () => {
await fetch('/api/submit', { method: 'POST', body: JSON.stringify(data) });
};
<Pressable onPress={handleSubmit}>
<Text>Submit</Text>
</Pressable>
// User can tap multiple times causing duplicate submissionsCorrect (disabled button with loading state):
import { useState } from 'react';
import { Pressable, Text, ActivityIndicator, StyleSheet } from 'react-native';
export default function SubmitForm() {
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async () => {
if (submitting) return; // Extra guard
setSubmitting(true);
try {
const response = await fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
if (!response.ok) {
throw new Error('Submission failed');
}
// Success - navigate or show confirmation
router.back();
} catch (error) {
// Show error to user
Alert.alert('Error', 'Failed to submit. Please try again.');
} finally {
setSubmitting(false);
}
};
return (
<Pressable
style={[styles.button, submitting && styles.buttonDisabled]}
onPress={handleSubmit}
disabled={submitting}
>
{submitting ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Submit</Text>
)}
</Pressable>
);
}
const styles = StyleSheet.create({
button: {
backgroundColor: '#007AFF',
padding: 16,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
minHeight: 50,
},
buttonDisabled: {
opacity: 0.6,
},
buttonText: {
color: '#fff',
fontWeight: '600',
fontSize: 16,
},
});Note: The disabled prop prevents taps, while the if (submitting) return guard prevents edge cases.
Reference: Pressable - React Native
Configure TextInput with Proper Keyboard and AutoComplete
Set keyboardType, autoComplete, and autoCapitalize for better input UX. The keyboard adapts to the expected input.
Incorrect (default keyboard for all inputs):
<TextInput placeholder="Email" />
<TextInput placeholder="Phone" />
<TextInput placeholder="Amount" />
// All show standard keyboard - no autocompleteCorrect (appropriate configuration per input type):
import { TextInput, View, StyleSheet } from 'react-native';
export default function SignUpForm() {
return (
<View style={styles.container}>
{/* Name - autocapitalize words */}
<TextInput
placeholder="Full Name"
autoCapitalize="words"
autoComplete="name"
textContentType="name" // iOS autofill
style={styles.input}
/>
{/* Email - email keyboard, no caps */}
<TextInput
placeholder="Email"
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
textContentType="emailAddress"
autoCorrect={false}
style={styles.input}
/>
{/* Phone - numeric keyboard */}
<TextInput
placeholder="Phone Number"
keyboardType="phone-pad"
autoComplete="tel"
textContentType="telephoneNumber"
style={styles.input}
/>
{/* Password - secure entry */}
<TextInput
placeholder="Password"
secureTextEntry
autoCapitalize="none"
autoComplete="new-password"
textContentType="newPassword" // iOS password suggestions
autoCorrect={false}
style={styles.input}
/>
{/* Currency - decimal keyboard */}
<TextInput
placeholder="Amount"
keyboardType="decimal-pad"
style={styles.input}
/>
{/* URL - URL keyboard */}
<TextInput
placeholder="Website"
keyboardType="url"
autoCapitalize="none"
autoCorrect={false}
style={styles.input}
/>
</View>
);
}
const styles = StyleSheet.create({
container: { padding: 16 },
input: { borderWidth: 1, borderColor: '#ddd', padding: 16, borderRadius: 8, marginBottom: 16 },
});Common keyboardType values: default, email-address, numeric, phone-pad, decimal-pad, url
Reference: TextInput - React Native
Set Up Android App Links
Android App Links allow your app to open when users tap links to your website. Requires hosting an assetlinks.json file.
Incorrect (missing autoVerify or wrong fingerprint):
{
"expo": {
"android": {
"package": "com.company.myapp",
"intentFilters": [
{
"action": "VIEW",
"data": [{ "scheme": "https", "host": "myapp.com" }],
"category": ["BROWSABLE", "DEFAULT"]
}
]
}
}
}Without autoVerify: true, Android shows a disambiguation dialog instead of opening the app directly.
Correct (complete App Links setup):
Step 1: Get SHA-256 fingerprint
keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android
eas credentials --platform androidStep 2: Create assetlinks.json (host at `/.well-known/assetlinks.json`)
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.company.myapp",
"sha256_cert_fingerprints": ["AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99"]
}
}
]Step 3: Configure app.json with autoVerify
{
"expo": {
"android": {
"package": "com.company.myapp",
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [
{ "scheme": "https", "host": "myapp.com", "pathPrefix": "/user" },
{ "scheme": "https", "host": "myapp.com", "pathPrefix": "/post" }
],
"category": ["BROWSABLE", "DEFAULT"]
}
]
}
}
}Testing:
curl https://myapp.com/.well-known/assetlinks.json
adb shell am start -W -a android.intent.action.VIEW -d "https://myapp.com/user/123" com.company.myappReference: Android App Links - Expo Documentation
Configure Custom URL Scheme for Deep Links
Configure a custom URL scheme to enable deep linking from other apps, emails, or websites. With Expo Router, all routes are automatically deep linkable.
Incorrect (no scheme configured):
// app.json - missing scheme
{
"expo": {
"name": "MyApp"
}
}
// Deep links like myapp://profile won't workCorrect (scheme configured):
// app.json
{
"expo": {
"name": "MyApp",
"scheme": "myapp",
"ios": {
"bundleIdentifier": "com.company.myapp"
},
"android": {
"package": "com.company.myapp"
}
}
}# Test deep link
npx uri-scheme open "myapp://profile" --ios
npx uri-scheme open "myapp://user/123" --android// Handle incoming links programmatically (optional)
import * as Linking from 'expo-linking';
import { useEffect } from 'react';
export default function App() {
useEffect(() => {
// Handle app opened via deep link
const subscription = Linking.addEventListener('url', ({ url }) => {
console.log('Opened with URL:', url);
// Expo Router handles navigation automatically
});
// Check if app was opened with a URL
Linking.getInitialURL().then((url) => {
if (url) console.log('Initial URL:', url);
});
return () => subscription.remove();
}, []);
return <Stack />;
}Note: Expo Router automatically routes incoming URLs to matching screens. No additional configuration needed.
Reference: Linking into your app - Expo Documentation
Handle Incoming Links with Expo Router
Expo Router automatically routes incoming URLs to matching screens. Use dynamic segments to capture URL parameters.
Incorrect (manual link parsing):
import * as Linking from 'expo-linking';
import { useEffect } from 'react';
export default function App() {
useEffect(() => {
const handleUrl = ({ url }: { url: string }) => {
const parsed = Linking.parse(url);
if (parsed.path?.startsWith('invite/')) {
const code = parsed.path.split('/')[1];
navigation.navigate('Invite', { code }); // Manual routing
}
};
Linking.addEventListener('url', handleUrl);
}, []);
}Correct (file-based routing handles links automatically):
app/
├── user/
│ └── [id].tsx # Handles myapp://user/123
├── post/
│ └── [postId].tsx # Handles myapp://post/abc
└── invite/
└── [code].tsx # Handles myapp://invite/XYZ123// app/invite/[code].tsx - Screen receives params automatically
import { useLocalSearchParams, router } from 'expo-router';
import { useEffect, useState } from 'react';
import { View, Text, Button, ActivityIndicator } from 'react-native';
export default function InviteScreen() {
const { code } = useLocalSearchParams<{ code: string }>();
const [invite, setInvite] = useState<Invite | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function validateInvite() {
const response = await fetch(`/api/invites/${code}`);
const data = await response.json();
setInvite(data);
setLoading(false);
}
validateInvite();
}, [code]);
if (loading) return <ActivityIndicator />;
return (
<View style={{ flex: 1, padding: 16 }}>
<Text style={{ fontSize: 24 }}>You're invited!</Text>
<Text>{invite?.message}</Text>
<Button title="Accept" onPress={() => router.replace('/')} />
</View>
);
}Note: Expo Router handles both cold starts and warm links automatically.
Reference: Customizing links - Expo Documentation
Set Up iOS Universal Links
Universal Links open your app when users tap links to your website. Requires hosting an apple-app-site-association file.
Incorrect (missing or misconfigured AASA):
{
"expo": {
"ios": {
"bundleIdentifier": "com.company.myapp",
"associatedDomains": ["https://myapp.com"]
}
}
}The https:// prefix breaks Universal Links - iOS silently ignores this configuration.
Correct (proper AASA and app.json setup):
Step 1: Create AASA file (host at `/.well-known/apple-app-site-association`)
{
"applinks": {
"apps": [],
"details": [
{
"appIDs": ["TEAM_ID.com.company.myapp"],
"components": [
{ "/": "/user/*" },
{ "/": "/post/*" },
{ "/": "/invite/*" }
]
}
]
}
}Step 2: Configure app.json (no https:// prefix)
{
"expo": {
"ios": {
"bundleIdentifier": "com.company.myapp",
"associatedDomains": ["applinks:myapp.com", "applinks:www.myapp.com"]
}
}
}Requirements:
- HTTPS hosting (no HTTP)
- Content-Type:
application/json - No redirects
- Accessible without authentication
Testing:
curl -I https://myapp.com/.well-known/apple-app-site-association
curl "https://app-site-association.cdn-apple.com/a/v1/myapp.com"Reference: iOS Universal Links - Expo Documentation
Configure Drawer Navigator for Side Menu
Use the Drawer navigator for apps with a side menu. Requires react-native-reanimated and react-native-gesture-handler.
Incorrect (manual drawer implementation):
// Custom drawer with manual animation - complex and error-prone
import { useState } from 'react';
import { View, Animated, Pressable } from 'react-native';
export default function App() {
const [drawerOpen, setDrawerOpen] = useState(false);
const translateX = useRef(new Animated.Value(-250)).current;
const toggleDrawer = () => {
Animated.timing(translateX, {
toValue: drawerOpen ? -250 : 0,
duration: 300,
}).start();
setDrawerOpen(!drawerOpen);
};
// 50+ more lines of manual drawer logic...
}Correct (Expo Router Drawer):
npx expo install @react-navigation/drawer react-native-reanimated react-native-gesture-handler// app/_layout.tsx - Simple drawer setup
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { Drawer } from 'expo-router/drawer';
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<Drawer>
<Drawer.Screen name="index" options={{ drawerLabel: 'Home', title: 'Home' }} />
<Drawer.Screen name="settings" options={{ drawerLabel: 'Settings', title: 'Settings' }} />
<Drawer.Screen name="profile" options={{ drawerLabel: 'Profile', title: 'Profile' }} />
</Drawer>
</GestureHandlerRootView>
);
}// app/index.tsx - Open drawer programmatically
import { View, Text, Button } from 'react-native';
import { DrawerActions, useNavigation } from '@react-navigation/native';
export default function HomeScreen() {
const navigation = useNavigation();
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>Home Screen</Text>
<Button title="Open Drawer" onPress={() => navigation.dispatch(DrawerActions.openDrawer())} />
</View>
);
}Reference: Drawer - Expo Documentation
Use Dynamic Route Segments with Brackets
Use square brackets [param] in filenames to create dynamic route segments. Access parameters with useLocalSearchParams().
Incorrect (hardcoded routes for each item):
app/
├── user-1.tsx
├── user-2.tsx
└── user-3.tsxCorrect (dynamic route segment):
app/
├── _layout.tsx
├── index.tsx
└── user/
└── [id].tsx # Matches /user/1, /user/abc, etc.// app/user/[id].tsx
import { useLocalSearchParams } from 'expo-router';
import { View, Text } from 'react-native';
export default function UserScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
return (
<View>
<Text>User ID: {id}</Text>
</View>
);
}// Navigating to dynamic route
import { Link, router } from 'expo-router';
// Using Link component
<Link href="/user/123">View User 123</Link>
// Using router imperatively
router.push('/user/123');
// With typed routes (requires experiments.typedRoutes)
router.push({ pathname: '/user/[id]', params: { id: '123' } });Catch-all routes: Use [...slug].tsx to match multiple segments like /docs/a/b/c.
Reference: Core concepts - Expo Documentation
Use File-Based Routing with Expo Router
Expo Router uses file-based routing where files in the app/ directory automatically become routes. Every screen is automatically deep linkable.
Incorrect (manual route configuration):
// Manual stack configuration without file structure
const Stack = createNativeStackNavigator();
function App() {
return (
<Stack.Navigator>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen name="Profile" component={ProfileScreen} />
<Stack.Screen name="Settings" component={SettingsScreen} />
</Stack.Navigator>
);
}Correct (file-based routes):
app/
├── _layout.tsx # Root layout (Stack navigator)
├── index.tsx # "/" - Home screen
├── profile.tsx # "/profile" - Profile screen
└── settings.tsx # "/settings" - Settings screen// app/_layout.tsx
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
<Stack>
<Stack.Screen name="index" options={{ title: 'Home' }} />
<Stack.Screen name="profile" options={{ title: 'Profile' }} />
<Stack.Screen name="settings" options={{ title: 'Settings' }} />
</Stack>
);
}// app/index.tsx
import { Link } from 'expo-router';
import { View, Text } from 'react-native';
export default function HomeScreen() {
return (
<View>
<Text>Home Screen</Text>
<Link href="/profile">Go to Profile</Link>
</View>
);
}Present Screens as Modals with presentation Option
Use the presentation: 'modal' option to present screens as overlays. Modals are useful for forms, confirmations, and focused tasks.
Incorrect (modal as regular stack screen):
// No presentation option - opens as regular push
<Stack.Screen name="create-post" />Correct (modal presentation):
app/
├── _layout.tsx
├── (tabs)/
│ └── ...
└── create-post.tsx # Modal screen// app/_layout.tsx
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="create-post"
options={{
presentation: 'modal',
title: 'Create Post',
headerLeft: () => null, // Remove back button
}}
/>
</Stack>
);
}// app/create-post.tsx
import { router } from 'expo-router';
import { View, Text, Button } from 'react-native';
export default function CreatePostModal() {
const handleSubmit = async () => {
// Save post...
router.back(); // Dismiss modal
};
return (
<View style={{ flex: 1, padding: 16 }}>
<Text>Create a new post</Text>
{/* Form fields */}
<Button title="Submit" onPress={handleSubmit} />
<Button title="Cancel" onPress={() => router.back()} />
</View>
);
}Presentation options: modal, transparentModal, containedModal, containedTransparentModal, fullScreenModal, formSheet
Reference: Modals - Expo Documentation
Nest Stack Navigator Inside Tab for Multi-Screen Sections
Each tab can contain its own stack navigator, allowing users to drill into details while maintaining tab context.
Incorrect (flat structure loses navigation hierarchy):
app/
└── (tabs)/
├── _layout.tsx
├── feed.tsx
└── feed-detail.tsx # Not part of feed stackCorrect (nested stack within tab):
app/
└── (tabs)/
├── _layout.tsx
└── feed/
├── _layout.tsx # Stack navigator for feed tab
├── index.tsx # Feed list
└── [postId].tsx # Post detail (stays in feed tab)// app/(tabs)/feed/_layout.tsx
import { Stack } from 'expo-router';
export default function FeedLayout() {
return (
<Stack>
<Stack.Screen
name="index"
options={{ title: 'Feed' }}
/>
<Stack.Screen
name="[postId]"
options={{ title: 'Post' }}
/>
</Stack>
);
}// app/(tabs)/feed/index.tsx
import { Link } from 'expo-router';
import { FlatList, Pressable, Text } from 'react-native';
export default function FeedScreen() {
const posts = [{ id: '1', title: 'First Post' }];
return (
<FlatList
data={posts}
renderItem={({ item }) => (
<Link href={`/feed/${item.id}`} asChild>
<Pressable>
<Text>{item.title}</Text>
</Pressable>
</Link>
)}
/>
);
}Note: When navigating to /feed/123, the user stays in the Feed tab with back navigation to the feed list.
Configure Tab Navigator with Route Groups
Use a route group with parentheses (tabs) to create a tab navigator. Each file in the group becomes a tab.
Incorrect (tabs defined outside proper group structure):
// No route group, manual tab setup
<Tabs>
<Tabs.Screen name="home" />
<Tabs.Screen name="search" />
</Tabs>Correct (route group with proper layout):
app/
├── _layout.tsx # Root layout
└── (tabs)/
├── _layout.tsx # Tab navigator layout
├── index.tsx # First tab (Home)
├── search.tsx # Second tab (Search)
└── profile.tsx # Third tab (Profile)// app/(tabs)/_layout.tsx
import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
export default function TabLayout() {
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: '#007AFF',
headerShown: true,
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarIcon: ({ color, size }) => (
<Ionicons name="home" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="search"
options={{
title: 'Search',
tabBarIcon: ({ color, size }) => (
<Ionicons name="search" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
tabBarIcon: ({ color, size }) => (
<Ionicons name="person" size={size} color={color} />
),
}}
/>
</Tabs>
);
}// app/_layout.tsx - Root layout wrapping tabs
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
</Stack>
);
}Reference: Navigation layouts - Expo Documentation
Enable Typed Routes for Static Type Safety
Enable typed routes to get compile-time checking for navigation paths. Invalid routes cause TypeScript errors instead of runtime crashes.
Incorrect (string paths without type checking):
// Typo won't be caught until runtime
router.push('/proflie'); // Should be '/profile'
// Wrong params not detected
router.push({ pathname: '/user/[id]', params: { userId: '123' } });
// Should be { id: '123' }Correct (enable typed routes):
// app.json
{
"expo": {
"experiments": {
"typedRoutes": true
}
}
}// Now TypeScript catches errors
import { router, Link, Href } from 'expo-router';
// Error: '/proflie' is not a valid route
router.push('/proflie');
// Correct usage with autocomplete
router.push('/profile');
// Typed params
router.push({
pathname: '/user/[id]',
params: { id: '123' } // TypeScript knows 'id' is required
});
// Link component also typed
<Link href="/user/123">User</Link>
// Custom typed href
const userHref: Href = {
pathname: '/user/[id]',
params: { id: '123' }
};Note: Run npx expo start to generate route types in .expo/types/router.d.ts. Types update automatically during development.
Reference: Typed routes - Expo Documentation
Pass Minimal Data via Route Params
Pass only the ID via route params, then fetch full data on the detail screen. Passing entire objects leads to stale data and serialization issues.
Incorrect (passing entire object via params):
// List screen - passes entire post object
router.push({
pathname: '/post/[id]',
params: {
id: post.id,
title: post.title,
body: post.body,
author: JSON.stringify(post.author), // Serialization issues
createdAt: post.createdAt.toISOString(),
}
});
// Detail screen - uses potentially stale data
const { title, body, author } = useLocalSearchParams();Correct (pass ID, fetch on detail screen):
// List screen - passes only ID
import { Link } from 'expo-router';
<Link href={`/post/${post.id}`}>
<Text>{post.title}</Text>
</Link>// app/post/[id].tsx - fetches fresh data
import { useLocalSearchParams } from 'expo-router';
import { useEffect, useState } from 'react';
import { View, Text, ActivityIndicator } from 'react-native';
interface Post {
id: string;
title: string;
body: string;
}
export default function PostDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const [post, setPost] = useState<Post | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchPost() {
const response = await fetch(`/api/posts/${id}`);
const data = await response.json();
setPost(data);
setLoading(false);
}
fetchPost();
}, [id]);
if (loading) return <ActivityIndicator />;
if (!post) return <Text>Post not found</Text>;
return (
<View style={{ padding: 16 }}>
<Text style={{ fontSize: 24 }}>{post.title}</Text>
<Text>{post.body}</Text>
</View>
);
}Reference: Using URL parameters - Expo Documentation
Configure Screen Headers with Stack.Screen Options
Use options on Stack.Screen for static config or useNavigation().setOptions() for dynamic updates based on data.
Incorrect (hardcoded header, no customization):
// Default header with no customization
export default function ProfileScreen() {
return <View>...</View>;
}Correct (static and dynamic header options):
// app/_layout.tsx - Static options in layout
import { Stack } from 'expo-router';
export default function Layout() {
return (
<Stack>
<Stack.Screen
name="profile"
options={{
title: 'Profile',
headerStyle: { backgroundColor: '#f5f5f5' },
headerTintColor: '#333',
headerRight: () => (
<Pressable onPress={() => router.push('/settings')}>
<Ionicons name="settings" size={24} />
</Pressable>
),
}}
/>
</Stack>
);
}// app/post/[id].tsx - Dynamic options based on data
import { useNavigation } from 'expo-router';
import { useEffect, useState } from 'react';
import { View, Text, Share, Pressable } from 'react-native';
export default function PostScreen() {
const navigation = useNavigation();
const [post, setPost] = useState<{ title: string } | null>(null);
useEffect(() => {
if (post) {
navigation.setOptions({
title: post.title,
headerRight: () => (
<Pressable onPress={() => Share.share({ message: post.title })}>
<Ionicons name="share" size={24} />
</Pressable>
),
});
}
}, [post, navigation]);
return (
<View>
<Text>{post?.title}</Text>
</View>
);
}Common options: title, headerShown, headerStyle, headerTintColor, headerLeft, headerRight, headerBackTitle
Reference: Stack - Expo Documentation
Use FlashList for Large Lists Instead of FlatList
FlashList recycles components under the hood for dramatically better performance, especially on Android. It's a drop-in replacement for FlatList.
Incorrect (FlatList struggles with large lists):
import { FlatList, Text, View } from 'react-native';
export default function FeedScreen() {
return (
<FlatList
data={posts}
renderItem={({ item }) => (
<View style={{ padding: 16 }}>
<Text>{item.title}</Text>
</View>
)}
/>
);
}Correct (FlashList with estimatedItemSize):
npx expo install @shopify/flash-listimport { FlashList } from '@shopify/flash-list';
import { Text, View } from 'react-native';
interface Post {
id: string;
title: string;
body: string;
}
export default function FeedScreen() {
const posts: Post[] = [...];
return (
<FlashList
data={posts}
renderItem={({ item }) => (
<View style={{ padding: 16 }}>
<Text style={{ fontWeight: 'bold' }}>{item.title}</Text>
<Text>{item.body}</Text>
</View>
)}
estimatedItemSize={100} // Required: approximate item height
keyExtractor={(item) => item.id}
/>
);
}When to use FlatList instead:
- Very small lists (< 100 items)
- Lists with complex item layout changes during scroll
Reference: FlashList - Shopify
Show Loading and Error States for Async Screens
Always handle loading, error, and empty states. Users should never see a blank screen or wonder if the app is working.
Incorrect (no loading or error handling):
export default function FeedScreen() {
const [posts, setPosts] = useState([]);
useEffect(() => {
fetch('/api/posts')
.then(r => r.json())
.then(setPosts);
}, []);
return (
<FlatList data={posts} renderItem={...} /> // Blank until loaded
);
}Correct (explicit loading, error, and empty states):
import { View, Text, ActivityIndicator, Button } from 'react-native';
import { FlashList } from '@shopify/flash-list';
import { useState, useEffect } from 'react';
interface Post {
id: string;
title: string;
}
export default function FeedScreen() {
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchPosts = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/posts');
if (!response.ok) throw new Error('Failed to fetch');
const data = await response.json();
setPosts(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchPosts();
}, []);
if (loading) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<ActivityIndicator size="large" />
</View>
);
}
if (error) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text style={{ color: 'red' }}>{error}</Text>
<Button title="Retry" onPress={fetchPosts} />
</View>
);
}
if (posts.length === 0) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>No posts yet</Text>
</View>
);
}
return (
<FlashList
data={posts}
renderItem={({ item }) => <Text>{item.title}</Text>}
estimatedItemSize={50}
/>
);
}Reference: Error handling - Expo Documentation
Add Pull-to-Refresh with RefreshControl
Pull-to-refresh is an expected interaction on mobile lists. Use RefreshControl with FlashList or FlatList.
Incorrect (no refresh capability):
<FlashList
data={posts}
renderItem={({ item }) => <PostItem post={item} />}
estimatedItemSize={100}
/>
// User has no way to refresh without leaving screenCorrect (RefreshControl with loading state):
import { RefreshControl } from 'react-native';
import { FlashList } from '@shopify/flash-list';
import { useState, useCallback } from 'react';
interface Post {
id: string;
title: string;
}
export default function FeedScreen() {
const [posts, setPosts] = useState<Post[]>([]);
const [refreshing, setRefreshing] = useState(false);
const fetchPosts = async () => {
const response = await fetch('/api/posts');
const data = await response.json();
setPosts(data);
};
const onRefresh = useCallback(async () => {
setRefreshing(true);
await fetchPosts();
setRefreshing(false);
}, []);
return (
<FlashList
data={posts}
renderItem={({ item }) => <PostItem post={item} />}
estimatedItemSize={100}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor="#007AFF" // iOS spinner color
colors={['#007AFF']} // Android spinner color
/>
}
/>
);
}Note: refreshing must be controlled state - setting it to false hides the spinner.
Reference: RefreshControl - React Native
Build Settings Screens with Section Lists
Use SectionList for settings screens with grouped options. This follows native platform conventions.
Incorrect (flat list without grouping):
<View>
<Pressable><Text>Edit Profile</Text></Pressable>
<Pressable><Text>Notifications</Text></Pressable>
<Pressable><Text>Privacy</Text></Pressable>
<Pressable><Text>About</Text></Pressable>
<Pressable><Text>Log Out</Text></Pressable>
</View>Correct (SectionList with proper grouping):
import { SectionList, Text, Pressable, View, StyleSheet } from 'react-native';
import { router } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
interface SettingItem {
id: string;
title: string;
icon: keyof typeof Ionicons.glyphMap;
onPress: () => void;
destructive?: boolean;
}
const sections = [
{
title: 'Account',
data: [
{ id: 'profile', title: 'Edit Profile', icon: 'person', onPress: () => router.push('/settings/profile') },
{ id: 'notifications', title: 'Notifications', icon: 'notifications', onPress: () => router.push('/settings/notifications') },
{ id: 'privacy', title: 'Privacy', icon: 'lock-closed', onPress: () => router.push('/settings/privacy') },
] as SettingItem[],
},
{
title: 'Support',
data: [
{ id: 'help', title: 'Help Center', icon: 'help-circle', onPress: () => router.push('/settings/help') },
{ id: 'about', title: 'About', icon: 'information-circle', onPress: () => router.push('/settings/about') },
] as SettingItem[],
},
{
title: '',
data: [
{ id: 'logout', title: 'Log Out', icon: 'log-out', onPress: () => {}, destructive: true },
] as SettingItem[],
},
];
export default function SettingsScreen() {
return (
<SectionList
sections={sections}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<Pressable style={styles.row} onPress={item.onPress}>
<Ionicons name={item.icon} size={22} color={item.destructive ? 'red' : '#333'} />
<Text style={[styles.title, item.destructive && styles.destructive]}>
{item.title}
</Text>
<Ionicons name="chevron-forward" size={20} color="#ccc" />
</Pressable>
)}
renderSectionHeader={({ section: { title } }) =>
title ? <Text style={styles.sectionHeader}>{title}</Text> : null
}
stickySectionHeadersEnabled={false}
/>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', alignItems: 'center', padding: 16, backgroundColor: '#fff' },
title: { flex: 1, marginLeft: 12, fontSize: 16 },
destructive: { color: 'red' },
sectionHeader: { padding: 16, paddingBottom: 8, fontSize: 13, color: '#666', textTransform: 'uppercase' },
});Reference: SectionList - React Native
Use Typed App Config with app.config.ts
Use app.config.ts instead of app.json for type-safe configuration with autocomplete and compile-time validation.
Incorrect (no type safety in JSON):
{
"expo": {
"name": "MyApp",
"slug": "my-app",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"splash": {
"image": "./assets/splash.png"
}
}
}Correct (typed config with autocomplete):
import { ExpoConfig, ConfigContext } from 'expo/config';
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
name: 'MyApp',
slug: 'my-app',
version: '1.0.0',
orientation: 'portrait',
icon: './assets/icon.png',
splash: {
image: './assets/splash.png',
resizeMode: 'contain',
backgroundColor: '#ffffff',
},
ios: {
bundleIdentifier: 'com.company.myapp',
supportsTablet: true,
},
android: {
adaptiveIcon: {
foregroundImage: './assets/adaptive-icon.png',
backgroundColor: '#ffffff',
},
package: 'com.company.myapp',
},
plugins: ['expo-router'],
});Note: If both app.config.ts and app.config.js exist, TypeScript takes precedence.
Use Development Builds Instead of Expo Go for Production Apps
Expo Go is a sandbox for prototyping but cannot use custom native modules. Development builds are your own version of Expo Go with full native code access.
Incorrect (relying on Expo Go for production app development):
# Limited to Expo Go's bundled native modules
npx expo start
# Then scan QR code with Expo Go appCorrect (create a development build):
# Install expo-dev-client
npx expo install expo-dev-client
# Create development build for simulator
eas build --profile development --platform ios
# Or build locally
npx expo run:ios
npx expo run:android
# Start development server
npx expo start --dev-clientWhen to switch from Expo Go:
- Adding libraries with native code (e.g.,
react-native-maps) - Customizing native configuration
- Testing push notifications
- Testing deep links and universal links
- Any production-bound project
Reference: Expo Go vs Development Builds - Expo Documentation
Configure EAS Build Profiles for Each Environment
Define separate build profiles in eas.json for development, preview, and production environments with appropriate settings for each.
Incorrect (single profile for all environments):
{
"build": {
"production": {
"distribution": "store"
}
}
}Correct (distinct profiles per environment):
{
"cli": {
"version": ">= 5.0.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"ios": {
"simulator": true
},
"env": {
"EXPO_PUBLIC_ENVIRONMENT": "development"
}
},
"preview": {
"distribution": "internal",
"channel": "preview",
"env": {
"EXPO_PUBLIC_ENVIRONMENT": "preview"
}
},
"production": {
"distribution": "store",
"channel": "production",
"env": {
"EXPO_PUBLIC_ENVIRONMENT": "production"
}
}
},
"submit": {
"production": {}
}
}Note: Use channel to group builds for EAS Update. Development builds include expo-dev-client for debugging.
Reference: Configure EAS Build with eas.json - Expo Documentation
Related skills
FAQ
What stack does expo-react-native-coder target?
expo-react-native-coder targets Expo and React Native cross-platform mobile development. The skill guides agents implementing screens, navigation, and mobile features for iOS and Android within Expo-managed projects.
When should you use expo-react-native-coder?
expo-react-native-coder fits active mobile feature work—new screens, navigation changes, or Expo API usage—when coding agents need mobile-specific patterns instead of web React defaults.