
React Native
- 79 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
Helps with frontend development tasks during AI-assisted development.
About
react-native is a Claude Code skill for frontend development. It helps solo builders move faster with AI-assisted coding.
- react-native
- Frontend Development
- AI-coding skill
React Native by the numbers
- 79 all-time installs (skills.sh)
- +1 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,119 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill react-nativeAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 79 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
What it does
Helps with frontend development tasks during AI-assisted development.
Files
React Native with Expo
Overview
Expo is an open-source framework for building universal native apps with React Native from a single TypeScript codebase. It provides file-based routing (Expo Router), cloud build services (EAS Build/Submit/Update), and a rich set of native modules for device APIs.
When to use: Mobile apps targeting iOS and Android, universal apps with web support, projects needing OTA updates, apps requiring native device APIs (camera, notifications, haptics), teams preferring managed infrastructure over bare React Native.
When NOT to use: Apps requiring heavy custom native code incompatible with Expo Modules API, brownfield integration into existing native apps, apps with native-only CI/CD requirements.
Quick Reference
| Pattern | API | Key Points |
|---|---|---|
| Stack navigation | <Stack> from expo-router | File-based, _layout.tsx defines navigator |
| Tab navigation | <Tabs> from expo-router | (tabs) directory group with _layout.tsx |
| Drawer navigation | <Drawer> from expo-router/drawer | Requires @react-navigation/drawer |
| Typed routes | href with /(group)/route | Enable typed-routes in app.json |
| Dev build | expo-dev-client | Custom native code + dev tools |
| EAS Build | eas build --profile production | Cloud builds for app stores |
| EAS Submit | eas submit -p ios / -p android | Automated store submission |
| OTA Update | eas update --branch production | JS-only updates, no rebuild needed |
| Native module | Expo Modules API | Swift/Kotlin with expo-module.config.json |
| Platform code | Platform.select() / .ios.tsx | Per-platform logic or entire files |
| Styled lists | FlatList / FlashList | Virtualized, keyExtractor required |
| Camera | expo-camera | Permissions via useCameraPermissions() |
| Notifications | expo-notifications | Push tokens via getExpoPushTokenAsync() |
| Haptics | expo-haptics | impactAsync, notificationAsync |
| Tailwind (RN) | NativeWind v4 | className prop, Tailwind CSS in RN |
Common Mistakes
| Mistake | Correct Pattern |
|---|---|
Using react-navigation directly instead of Expo Router | Expo Router wraps React Navigation with file-based routing |
Forgetting _layout.tsx in route directories | Every directory with routes needs a layout file |
Using expo start for custom native code | Use expo start --dev-client with expo-dev-client |
Running eas update after native dependency changes | Native changes require eas build, OTA is JS-only |
Hardcoding platform checks with if | Use Platform.select() or platform-specific file extensions |
| Not requesting permissions before accessing device APIs | Always check/request permissions before camera, notifications |
Using ScrollView for long lists | Use FlatList or FlashList for virtualized rendering |
| Inline styles in render functions | Define styles with StyleSheet.create() outside component |
Missing keyExtractor on FlatList | Always provide keyExtractor for stable list rendering |
Importing from react-native for Expo-provided APIs | Prefer expo-* packages over react-native equivalents |
Delegation
- Pattern discovery: Use
Exploreagent - Build/deployment review: Use
Taskagent - Code review: Delegate to
code-revieweragent
If the react-patterns skill is available, delegate React component patterns and hooks to it.If the tanstack-query skill is available, delegate data fetching and caching to it.If the zustand skill is available, delegate client-side state management to it.If the vitest-testing skill is available, delegate unit and component testing patterns to it.If the tailwind skill is available, delegate Tailwind CSS utility patterns to it (NativeWind uses Tailwind CSS syntax).If the accessibility skill is available, delegate mobile accessibility patterns to it.References
- Core patterns: StyleSheet, FlatList, Platform-specific code
- Expo Router navigation: stack, tabs, drawer, typed routes
- EAS Build, Submit, and Update workflows
- Dev client and native modules with Expo Modules API
- Device APIs: camera, notifications, haptics
- NativeWind Tailwind CSS integration
- App configuration and environment setup
- Performance optimization patterns
App Configuration
app.json vs app.config.ts
app.json is static configuration. app.config.ts enables dynamic values and environment variables:
import { type ExpoConfig, type ConfigContext } from 'expo/config';
export default ({ config }: ConfigContext): ExpoConfig => ({
...config,
name: 'My App',
slug: 'my-app',
version: '1.0.0',
orientation: 'portrait',
icon: './assets/icon.png',
scheme: 'myapp',
userInterfaceStyle: 'automatic',
newArchEnabled: true,
splash: {
image: './assets/splash-icon.png',
resizeMode: 'contain',
backgroundColor: '#ffffff',
},
ios: {
supportsTablet: true,
bundleIdentifier: 'com.example.myapp',
},
android: {
adaptiveIcon: {
foregroundImage: './assets/adaptive-icon.png',
backgroundColor: '#ffffff',
},
package: 'com.example.myapp',
},
web: {
bundler: 'metro',
favicon: './assets/favicon.png',
},
experiments: {
typedRoutes: true,
},
plugins: [
'expo-router',
'expo-font',
[
'expo-camera',
{
cameraPermission: 'Allow $(PRODUCT_NAME) to access your camera.',
},
],
],
extra: {
apiUrl: process.env.API_URL ?? 'https://api.example.com',
eas: {
projectId: 'your-project-id',
},
},
});Environment Variables
Using .env Files
Expo supports .env files with the EXPO_PUBLIC_ prefix:
EXPO_PUBLIC_API_URL=https://api.example.com
EXPO_PUBLIC_SENTRY_DSN=https://sentry.io/123Access in code:
const apiUrl = process.env.EXPO_PUBLIC_API_URL;Variables without the EXPO_PUBLIC_ prefix are available in config files but not in app code.
Using expo-constants
Access config values at runtime:
import Constants from 'expo-constants';
const apiUrl = Constants.expoConfig?.extra?.apiUrl;
const appVersion = Constants.expoConfig?.version;Custom Fonts
Setup with expo-font
npx expo install expo-fontLoad fonts in the root layout:
import { useFonts } from 'expo-font';
import * as SplashScreen from 'expo-splash-screen';
import { useEffect } from 'react';
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const [fontsLoaded] = useFonts({
'Inter-Regular': require('../assets/fonts/Inter-Regular.ttf'),
'Inter-Bold': require('../assets/fonts/Inter-Bold.ttf'),
});
useEffect(() => {
if (fontsLoaded) {
SplashScreen.hideAsync();
}
}, [fontsLoaded]);
if (!fontsLoaded) return null;
return <Stack />;
}Use in styles:
const styles = StyleSheet.create({
heading: {
fontFamily: 'Inter-Bold',
fontSize: 24,
},
});Google Fonts
npx expo install @expo-google-fonts/inter expo-fontimport {
useFonts,
Inter_400Regular,
Inter_700Bold,
} from '@expo-google-fonts/inter';Splash Screen
Configure in app.json:
{
"expo": {
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#1a1a2e"
},
"ios": {
"splash": {
"image": "./assets/splash-ios.png",
"resizeMode": "cover"
}
},
"android": {
"splash": {
"image": "./assets/splash-android.png",
"resizeMode": "cover",
"backgroundColor": "#1a1a2e"
}
}
}
}Control splash visibility programmatically:
import * as SplashScreen from 'expo-splash-screen';
SplashScreen.preventAutoHideAsync();
async function initApp() {
await loadFonts();
await loadInitialData();
SplashScreen.hideAsync();
}Asset Management
Static Assets
import { Image } from 'expo-image';
<Image
source={require('../assets/logo.png')}
style={{ width: 200, height: 60 }}
/>;Asset Bundling
Pre-download assets for offline use:
import { Asset } from 'expo-asset';
async function cacheAssets() {
const images = [
require('../assets/splash.png'),
require('../assets/icon.png'),
];
const cacheImages = images.map((image) =>
Asset.fromModule(image).downloadAsync(),
);
await Promise.all(cacheImages);
}Metro Configuration
Custom Metro config for path aliases and additional file extensions:
const { getDefaultConfig } = require('expo/metro-config');
const config = getDefaultConfig(__dirname);
config.resolver.sourceExts = [...config.resolver.sourceExts, 'mjs', 'cjs'];
module.exports = config;Path Aliases with tsconfig
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
}
}import { Button } from '@/components/Button';
import { useAuth } from '@/hooks/useAuth';Expo's Metro config resolves tsconfig.json paths automatically.
Core Patterns
StyleSheet API
StyleSheet.create() validates styles at creation time and enables optimizations by sending styles over the bridge once.
import { View, Text, StyleSheet } from 'react-native';
function ProfileCard({ name, bio }: { name: string; bio: string }) {
return (
<View style={styles.card}>
<Text style={styles.name}>{name}</Text>
<Text style={styles.bio}>{bio}</Text>
</View>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: '#fff',
borderRadius: 12,
padding: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
name: {
fontSize: 18,
fontWeight: '600',
color: '#1a1a1a',
},
bio: {
fontSize: 14,
color: '#666',
marginTop: 4,
},
});Composing Styles
Pass an array to style for composition. Later values override earlier ones:
<View style={[styles.card, isActive && styles.activeCard]} />
<Text style={[styles.text, { color: theme.primary }]} />FlatList
Virtualized list rendering for large datasets. Only renders visible items plus a buffer.
import { FlatList, type ListRenderItem } from 'react-native';
type Item = { id: string; title: string };
function ItemList({ items }: { items: Item[] }) {
const renderItem: ListRenderItem<Item> = ({ item }) => (
<View style={styles.row}>
<Text>{item.title}</Text>
</View>
);
return (
<FlatList
data={items}
renderItem={renderItem}
keyExtractor={(item) => item.id}
ItemSeparatorComponent={() => <View style={styles.separator} />}
ListEmptyComponent={<Text>No items found</Text>}
contentContainerStyle={styles.listContent}
/>
);
}SectionList
Grouped data with section headers:
import { SectionList } from 'react-native';
type Section = { title: string; data: Item[] };
function GroupedList({ sections }: { sections: Section[] }) {
return (
<SectionList
sections={sections}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <ItemRow item={item} />}
renderSectionHeader={({ section }) => (
<Text style={styles.sectionHeader}>{section.title}</Text>
)}
stickySectionHeadersEnabled
/>
);
}FlashList (High Performance)
Drop-in replacement for FlatList from Shopify with recycling architecture:
import { FlashList } from '@shopify/flash-list';
function FastList({ items }: { items: Item[] }) {
return (
<FlashList
data={items}
renderItem={({ item }) => <ItemRow item={item} />}
estimatedItemSize={72}
keyExtractor={(item) => item.id}
/>
);
}estimatedItemSize is required. Measure average item height for optimal recycling.
Platform-Specific Code
Platform.select and Platform.OS
import { Platform, StyleSheet } from 'react-native';
const styles = StyleSheet.create({
shadow: Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
},
android: {
elevation: 3,
},
default: {
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
},
}),
});
function PlatformMessage() {
return <Text>{Platform.OS === 'ios' ? 'iPhone' : 'Android'} app</Text>;
}Platform-Specific File Extensions
Create files with platform extensions for entirely different implementations:
components/
Button.tsx # Shared/default
Button.ios.tsx # iOS-specific
Button.android.tsx # Android-specific
Button.web.tsx # Web-specificImport without the extension -- the bundler resolves the correct file:
import { Button } from './components/Button';Pressable
The current recommended touchable component, replacing TouchableOpacity:
import { Pressable, type PressableProps } from 'react-native';
function Button({ children, onPress, disabled }: PressableProps) {
return (
<Pressable
disabled={disabled}
style={({ pressed }) => [
styles.button,
pressed && styles.pressed,
disabled && styles.disabled,
]}
onPress={onPress}
>
{children}
</Pressable>
);
}SafeAreaView
Handles device notches, status bars, and home indicators:
import { SafeAreaView } from 'react-native-safe-area-context';
function Screen({ children }: { children: React.ReactNode }) {
return (
<SafeAreaView style={styles.screen} edges={['top', 'bottom']}>
{children}
</SafeAreaView>
);
}Use react-native-safe-area-context (not the built-in SafeAreaView from react-native) for cross-platform consistency and granular edge control.
KeyboardAvoidingView
Prevents the keyboard from covering input fields:
import { KeyboardAvoidingView, Platform } from 'react-native';
function FormScreen() {
return (
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.container}
>
<TextInput placeholder="Email" />
<TextInput placeholder="Password" secureTextEntry />
</KeyboardAvoidingView>
);
}Image Handling
import { Image } from 'expo-image';
function Avatar({ uri }: { uri: string }) {
return (
<Image
source={{ uri }}
style={styles.avatar}
contentFit="cover"
placeholder={{ blurhash: 'LKN]Rv%2Tw=w]~RBVZRi};RPxuwH' }}
transition={200}
/>
);
}Prefer expo-image over react-native's Image for caching, blurhash placeholders, and animated image support.
Device APIs
Camera
expo-camera provides camera access with barcode scanning and photo capture.
Setup
npx expo install expo-camera{
"expo": {
"plugins": [
[
"expo-camera",
{
"cameraPermission": "Allow $(PRODUCT_NAME) to access your camera for photo capture."
}
]
]
}
}Camera with Permissions
import { CameraView, useCameraPermissions } from 'expo-camera';
import { useState, useRef } from 'react';
import { View, Text, Pressable } from 'react-native';
function CameraScreen() {
const [permission, requestPermission] = useCameraPermissions();
const [facing, setFacing] = useState<'front' | 'back'>('back');
const cameraRef = useRef<CameraView>(null);
if (!permission) return <View />;
if (!permission.granted) {
return (
<View style={styles.container}>
<Text>Camera permission is required</Text>
<Pressable onPress={requestPermission}>
<Text>Grant Permission</Text>
</Pressable>
</View>
);
}
const takePicture = async () => {
const photo = await cameraRef.current?.takePictureAsync();
if (photo) {
console.log(photo.uri);
}
};
return (
<View style={styles.container}>
<CameraView ref={cameraRef} facing={facing} style={styles.camera}>
<Pressable
onPress={() => setFacing((f) => (f === 'back' ? 'front' : 'back'))}
>
<Text style={styles.button}>Flip</Text>
</Pressable>
<Pressable onPress={takePicture}>
<Text style={styles.button}>Capture</Text>
</Pressable>
</CameraView>
</View>
);
}Barcode Scanning
import { CameraView } from 'expo-camera';
function BarcodeScanner() {
const handleScan = ({ data, type }: { data: string; type: string }) => {
console.log(`Scanned ${type}: ${data}`);
};
return (
<CameraView
barcodeScannerSettings={{
barcodeTypes: ['qr', 'ean13', 'code128'],
}}
onBarcodeScanned={handleScan}
style={{ flex: 1 }}
/>
);
}Push Notifications
expo-notifications handles local and push notifications with scheduling support.
Setup
npx expo install expo-notifications expo-device expo-constantsRegister for Push Notifications
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import Constants from 'expo-constants';
import { Platform } from 'react-native';
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
});
async function registerForPushNotifications(): Promise<string | undefined> {
if (!Device.isDevice) {
throw new Error('Push notifications require a physical device');
}
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== 'granted') return undefined;
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('default', {
name: 'Default',
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
});
}
const projectId = Constants.expoConfig?.extra?.eas?.projectId;
const token = await Notifications.getExpoPushTokenAsync({ projectId });
return token.data;
}Listen for Notifications
import { useEffect, useRef } from 'react';
import * as Notifications from 'expo-notifications';
function useNotificationListeners() {
const notificationListener = useRef<Notifications.EventSubscription>();
const responseListener = useRef<Notifications.EventSubscription>();
useEffect(() => {
notificationListener.current =
Notifications.addNotificationReceivedListener((notification) => {
console.log('Received:', notification.request.content);
});
responseListener.current =
Notifications.addNotificationResponseReceivedListener((response) => {
const data = response.notification.request.content.data;
console.log('Tapped:', data);
});
return () => {
notificationListener.current?.remove();
responseListener.current?.remove();
};
}, []);
}Schedule Local Notification
import * as Notifications from 'expo-notifications';
async function scheduleReminder(title: string, body: string, seconds: number) {
await Notifications.scheduleNotificationAsync({
content: { title, body, sound: true },
trigger: {
seconds,
type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL,
},
});
}Haptic Feedback
expo-haptics provides tactile feedback patterns.
npx expo install expo-hapticsimport * as Haptics from 'expo-haptics';
function HapticButtons() {
return (
<View>
<Pressable
onPress={() => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)}
>
<Text>Light Impact</Text>
</Pressable>
<Pressable
onPress={() => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium)}
>
<Text>Medium Impact</Text>
</Pressable>
<Pressable
onPress={() => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy)}
>
<Text>Heavy Impact</Text>
</Pressable>
<Pressable
onPress={() =>
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success)
}
>
<Text>Success</Text>
</Pressable>
<Pressable onPress={() => Haptics.selectionAsync()}>
<Text>Selection</Text>
</Pressable>
</View>
);
}| Method | Purpose |
|---|---|
impactAsync(style) | Physical impact (Light, Medium, Heavy) |
notificationAsync(type) | Outcome feedback (Success, Warning, Error) |
selectionAsync() | Selection change feedback |
Location
npx expo install expo-locationimport * as Location from 'expo-location';
async function getCurrentLocation() {
const { status } = await Location.requestForegroundPermissionsAsync();
if (status !== 'granted') return null;
const location = await Location.getCurrentPositionAsync({
accuracy: Location.Accuracy.High,
});
return location.coords;
}Secure Storage
npx expo install expo-secure-storeimport * as SecureStore from 'expo-secure-store';
async function saveToken(key: string, value: string) {
await SecureStore.setItemAsync(key, value);
}
async function getToken(key: string): Promise<string | null> {
return SecureStore.getItemAsync(key);
}Uses Keychain on iOS and EncryptedSharedPreferences on Android. Suitable for auth tokens, API keys, and sensitive user data.
EAS Workflows
Expo Application Services (EAS) provides cloud build, app store submission, and over-the-air (OTA) update services.
EAS Build
Default Configuration
eas.json at project root:
{
"cli": {
"version": ">= 13.0.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"channel": "development"
},
"preview": {
"distribution": "internal",
"channel": "preview"
},
"production": {
"channel": "production",
"autoIncrement": true
}
}
}Build Commands
eas build --profile development --platform ios
eas build --profile development --platform android
eas build --profile production --platform all
eas build --profile preview --platform ios --localBuild Profiles
| Profile | Distribution | Purpose |
|---|---|---|
development | internal | Dev client for testing with native modules |
preview | internal | Internal testing builds (ad-hoc/APK) |
production | store (default) | App store release builds |
Platform-Specific Overrides
{
"build": {
"production": {
"ios": {
"buildConfiguration": "Release",
"image": "macos-ventura-14.2-xcode-15.3"
},
"android": {
"buildType": "app-bundle",
"image": "ubuntu-22.04-jdk-17-ndk-25"
}
}
}
}Environment Variables
{
"build": {
"production": {
"env": {
"API_URL": "https://api.example.com"
}
},
"preview": {
"env": {
"API_URL": "https://staging.api.example.com"
}
}
}
}Access in code via process.env.API_URL or expo-constants.
EAS Submit
Automated app store submission after build completion.
Submit Configuration
{
"submit": {
"production": {
"ios": {
"appleId": "team@example.com",
"ascAppId": "1234567890",
"appleTeamId": "AB12XYZ34S"
},
"android": {
"track": "internal",
"serviceAccountKeyPath": "./google-service-account.json"
}
}
}
}Submit Commands
eas submit -p ios --latest
eas submit -p android --latest
eas submit -p ios --id <build-id>
eas build --profile production --platform all --auto-submitThe --auto-submit flag triggers submission immediately after a successful build.
Android Tracks
| Track | Purpose |
|---|---|
internal | Internal testing (up to 100 testers) |
alpha | Closed testing |
beta | Open testing |
production | Public release |
EAS Update (OTA)
Delivers JavaScript and asset updates without app store review. Only works for JS/asset changes -- native code changes require a new build.
Publishing Updates
eas update --branch production --message "Fix checkout bug"
eas update --branch preview --message "New feature preview"
eas update --channel developmentChannel-Branch Mapping
Channels connect builds to update branches:
Build Profile → Channel → Branch
production → production → production
preview → preview → preview
development → development → developmentMap a channel to a branch:
eas channel:edit production --branch production
eas channel:edit preview --branch stagingRuntime Version Policy
Configure in app.json to control which builds receive which updates:
{
"expo": {
"runtimeVersion": {
"policy": "appVersion"
}
}
}| Policy | Behavior |
|---|---|
appVersion | Runtime version matches version field |
nativeVersion | Based on native build number |
fingerprint | Auto-generated from native dependencies |
fingerprint is recommended -- it automatically detects native dependency changes and prevents incompatible updates.
Checking for Updates Programmatically
import * as Updates from 'expo-updates';
async function checkForUpdates() {
if (__DEV__) return;
const update = await Updates.checkForUpdateAsync();
if (update.isAvailable) {
await Updates.fetchUpdateAsync();
await Updates.reloadAsync();
}
}Rollback
eas update:rollback --branch productionRollback creates a new update pointing to the previous version.
CI/CD Integration
GitHub Actions Example
name: EAS Build
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 20
- uses: expo/expo-github-action@v8
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- run: npm install
- run: eas build --profile production --platform all --non-interactive
- run: eas submit --platform all --latest --non-interactiveEAS Metadata (Store Listings)
Manage app store metadata alongside code:
eas metadata:pull
eas metadata:pushConfiguration in store.config.json at project root handles app descriptions, screenshots, and store-specific fields.
Expo Router Navigation
Expo Router provides file-based routing for React Native, mapping filesystem paths directly to navigation routes. It wraps React Navigation and adds automatic deep linking, typed routes, and universal links.
File System Structure
app/
_layout.tsx # Root layout (Stack, Tabs, etc.)
index.tsx # / route
about.tsx # /about route
settings/
_layout.tsx # Nested layout for /settings/*
index.tsx # /settings route
profile.tsx # /settings/profile route
[id].tsx # Dynamic route: /123, /abc
[...rest].tsx # Catch-all: /any/nested/path
+not-found.tsx # 404 handlerRoot Stack Layout
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="modal" options={{ presentation: 'modal' }} />
<Stack.Screen name="+not-found" />
</Stack>
);
}Tab Navigation
Create a (tabs) directory group with its own _layout.tsx:
app/
(tabs)/
_layout.tsx # Tab navigator
index.tsx # First tab (Home)
explore.tsx # Second tab (Explore)
profile.tsx # Third tab (Profile)import { Tabs } from 'expo-router';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
export default function TabLayout() {
return (
<Tabs screenOptions={{ tabBarActiveTintColor: '#007AFF' }}>
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarIcon: ({ color }) => (
<MaterialIcons name="home" size={28} color={color} />
),
}}
/>
<Tabs.Screen
name="explore"
options={{
title: 'Explore',
tabBarIcon: ({ color }) => (
<MaterialIcons name="search" size={28} color={color} />
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: 'Profile',
tabBarIcon: ({ color }) => (
<MaterialIcons name="person" size={28} color={color} />
),
}}
/>
</Tabs>
);
}Drawer Navigation
Requires @react-navigation/drawer and react-native-gesture-handler:
import { Drawer } from 'expo-router/drawer';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
export default function DrawerLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<Drawer>
<Drawer.Screen name="index" options={{ drawerLabel: 'Home' }} />
<Drawer.Screen name="settings" options={{ drawerLabel: 'Settings' }} />
</Drawer>
</GestureHandlerRootView>
);
}Navigation Patterns
Programmatic Navigation
import { router } from 'expo-router';
router.push('/profile/123');
router.replace('/login');
router.back();
router.canGoBack();
router.dismiss();
router.dismissAll();Link Component
import { Link } from 'expo-router';
function NavLink() {
return (
<Link href="/profile/123" asChild>
<Pressable>
<Text>View Profile</Text>
</Pressable>
</Link>
);
}Dynamic Routes
File: app/user/[id].tsx
import { useLocalSearchParams } from 'expo-router';
export default function UserScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
return <Text>User: {id}</Text>;
}Route Groups
Parenthesized directories create logical groups without affecting URL paths:
app/
(auth)/
_layout.tsx # Auth-specific layout
login.tsx # /login
register.tsx # /register
(app)/
_layout.tsx # Authenticated layout
index.tsx # /
profile.tsx # /profileTyped Routes
Enable in app.json:
{
"expo": {
"experiments": {
"typedRoutes": true
}
}
}Provides compile-time route validation:
import { router } from 'expo-router';
router.push('/profile/123');
router.push('/nonexistent');Modal Screens
import { Stack } from 'expo-router';
export default function RootLayout() {
return (
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="modal"
options={{
presentation: 'modal',
headerTitle: 'Details',
}}
/>
</Stack>
);
}Present modals with router.push('/modal'). Use router.dismiss() to close.
Deep Linking
Expo Router generates deep link handling automatically. Configure the scheme in app.json:
{
"expo": {
"scheme": "myapp"
}
}Links like myapp://profile/123 automatically resolve to app/profile/[id].tsx.
Screen Options from Route
Set header options dynamically within a route file:
import { Stack } from 'expo-router';
export default function ProfileScreen() {
return (
<>
<Stack.Screen options={{ title: 'My Profile' }} />
<View>
<Text>Profile content</Text>
</View>
</>
);
}Error Boundaries
import { ErrorBoundary } from 'expo-router';
export { ErrorBoundary };
export default function Screen() {
return <RiskyComponent />;
}Export ErrorBoundary from any route file to catch errors at that level.
Native Modules
expo-dev-client
Development builds with expo-dev-client create a custom version of Expo Go that includes your native dependencies. This enables development with any native library while retaining Expo's developer tools.
Setup
npx expo install expo-dev-client
eas build --profile development --platform iosStart the development server for a dev client build:
npx expo start --dev-clientWhen Dev Client Is Required
- Installing native libraries not included in Expo Go (e.g.,
react-native-ble-plx) - Using custom native modules built with Expo Modules API
- Configuring native project settings via config plugins
- Testing push notifications on physical devices
Development vs Production Flow
Development:
expo-dev-client → eas build --profile development → npx expo start --dev-client
Production:
eas build --profile production → eas submit → app storeExpo Modules API
The Expo Modules API enables building native modules in Swift and Kotlin with a declarative API, without touching Objective-C or Java.
Create a Module
npx create-expo-module my-moduleGenerated structure:
my-module/
src/
index.ts # TypeScript API
MyModule.ts # Module definition
ios/
MyModule.swift # Swift implementation
android/
src/main/java/expo/modules/mymodule/
MyModule.kt # Kotlin implementation
expo-module.config.json # Module configurationModule Configuration
expo-module.config.json:
{
"platforms": ["ios", "android"],
"ios": {
"modules": ["MyModule"]
},
"android": {
"modules": ["expo.modules.mymodule.MyModule"]
}
}Swift Module
import ExpoModulesCore
public class MyModule: Module {
public func definition() -> ModuleDefinition {
Name("MyModule")
Function("hello") { (name: String) -> String in
return "Hello, \(name)!"
}
AsyncFunction("fetchData") { (url: String) -> String in
let (data, _) = try await URLSession.shared.data(
from: URL(string: url)!
)
return String(data: data, encoding: .utf8) ?? ""
}
Events("onStatusChange")
Property("platform") {
return "ios"
}
}
}Kotlin Module
package expo.modules.mymodule
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class MyModule : Module() {
override fun definition() = ModuleDefinition {
Name("MyModule")
Function("hello") { name: String ->
"Hello, $name!"
}
AsyncFunction("fetchData") { url: String ->
java.net.URL(url).readText()
}
Events("onStatusChange")
Property("platform") {
"android"
}
}
}TypeScript API
import { requireNativeModule } from 'expo-modules-core';
const MyModule = requireNativeModule('MyModule');
export function hello(name: string): string {
return MyModule.hello(name);
}
export async function fetchData(url: string): Promise<string> {
return MyModule.fetchData(url);
}
export const platform: string = MyModule.platform;Native Views
Expose native UI components to React:
import ExpoModulesCore
public class MyViewModule: Module {
public func definition() -> ModuleDefinition {
Name("MyView")
View(MyNativeView.self) {
Prop("color") { (view, color: UIColor) in
view.backgroundColor = color
}
Events("onTap")
}
}
}import { requireNativeViewManager } from 'expo-modules-core';
const NativeView = requireNativeViewManager('MyView');
function MyView({ color, onTap }: { color: string; onTap: () => void }) {
return <NativeView color={color} onTap={onTap} style={{ flex: 1 }} />;
}Config Plugins
Config plugins modify native project configuration during prebuild without ejecting:
import { type ConfigPlugin, withInfoPlist } from 'expo/config-plugins';
const withCustomConfig: ConfigPlugin = (config) => {
return withInfoPlist(config, (modConfig) => {
modConfig.modResults.NSLocationWhenInUseUsageDescription =
'Required for nearby search';
return modConfig;
});
};
export default withCustomConfig;Register in app.json:
{
"expo": {
"plugins": ["./plugins/withCustomConfig"]
}
}Common Config Plugin Use Cases
| Plugin | Purpose |
|---|---|
expo-camera | Camera permissions strings |
expo-notifications | Push notification entitlements |
expo-location | Background location modes |
expo-build-properties | Native build settings (min SDK, Swift version) |
{
"expo": {
"plugins": [
[
"expo-build-properties",
{
"ios": {
"deploymentTarget": "15.1"
},
"android": {
"minSdkVersion": 24,
"compileSdkVersion": 34
}
}
]
]
}
}Local Modules
For project-specific native code, create modules within the project:
modules/
my-local-module/
src/
index.ts
ios/
MyLocalModule.swift
android/
src/main/java/expo/modules/mylocalmodule/
MyLocalModule.kt
expo-module.config.jsonRegister in package.json:
{
"expo": {
"autolinking": {
"nativeModulesDir": "./modules"
}
}
}Local modules are automatically linked during prebuild.
NativeWind Integration
NativeWind v4 brings Tailwind CSS to React Native, enabling className prop styling with the full Tailwind utility set. It compiles Tailwind classes to React Native styles at build time.
Setup
npx expo install nativewind tailwindcss react-native-reanimatedtailwind.config.ts
import { type Config } from 'tailwindcss';
export default {
content: ['./app/**/*.{ts,tsx}', './components/**/*.{ts,tsx}'],
presets: [require('nativewind/preset')],
theme: {
extend: {},
},
plugins: [],
} satisfies Config;global.css
@tailwind base;
@tailwind components;
@tailwind utilities;babel.config.js
module.exports = function (api) {
api.cache(true);
return {
presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }]],
plugins: ['nativewind/babel'],
};
};metro.config.js
const { getDefaultConfig } = require('expo/metro-config');
const { withNativeWind } = require('nativewind/metro');
const config = getDefaultConfig(__dirname);
module.exports = withNativeWind(config, { input: './global.css' });Import Global Styles
In app/_layout.tsx:
import '../global.css';
export default function RootLayout() {
return <Stack />;
}Basic Usage
import { View, Text, Pressable } from 'react-native';
function Card({ title, description }: { title: string; description: string }) {
return (
<View className="rounded-xl bg-white p-4 shadow-md dark:bg-gray-800">
<Text className="text-lg font-semibold text-gray-900 dark:text-white">
{title}
</Text>
<Text className="mt-1 text-sm text-gray-500 dark:text-gray-400">
{description}
</Text>
</View>
);
}
function PrimaryButton({
title,
onPress,
}: {
title: string;
onPress: () => void;
}) {
return (
<Pressable
className="items-center rounded-lg bg-blue-600 px-4 py-3 active:bg-blue-700"
onPress={onPress}
>
<Text className="text-base font-medium text-white">{title}</Text>
</Pressable>
);
}Platform-Specific Classes
NativeWind supports platform variants:
<View className="p-4 ios:pb-8 android:pb-4 web:max-w-lg web:mx-auto" />
<Text className="text-base ios:font-semibold android:font-bold" />Dark Mode
NativeWind supports the dark: variant using the device color scheme:
import { useColorScheme } from 'nativewind';
function ThemeToggle() {
const { colorScheme, toggleColorScheme } = useColorScheme();
return (
<Pressable
className="rounded-lg bg-gray-200 p-3 dark:bg-gray-700"
onPress={toggleColorScheme}
>
<Text className="text-gray-900 dark:text-white">
{colorScheme === 'dark' ? 'Light Mode' : 'Dark Mode'}
</Text>
</Pressable>
);
}State Variants
<Pressable className="bg-blue-500 active:bg-blue-700 disabled:opacity-50">
<Text className="text-white">Press Me</Text>
</Pressable>
<TextInput
className="rounded border border-gray-300 p-3 focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
placeholder="Enter text"
/>Custom Components with className
Use cssInterop to enable className on third-party components:
import { cssInterop } from 'nativewind';
import { Image } from 'expo-image';
import Svg from 'react-native-svg';
cssInterop(Image, { className: 'style' });
cssInterop(Svg, { className: 'style' });After wrapping, use className directly:
<Image className="h-12 w-12 rounded-full" source={{ uri: avatarUrl }} />Responsive Design
NativeWind supports breakpoint prefixes based on screen width:
<View className="flex-col sm:flex-row">
<View className="w-full sm:w-1/2">
<Text>Left column</Text>
</View>
<View className="w-full sm:w-1/2">
<Text>Right column</Text>
</View>
</View>| Prefix | Min Width |
|---|---|
sm: | 640px |
md: | 768px |
lg: | 1024px |
xl: | 1280px |
Combining with StyleSheet
When NativeWind classes are insufficient, combine with inline styles:
import { type ViewStyle } from 'react-native';
function AnimatedBox({ translateY }: { translateY: ViewStyle['transform'] }) {
return (
<View
className="rounded-lg bg-blue-500 p-4"
style={{ transform: translateY }}
/>
);
}style prop values override NativeWind-generated styles.
TypeScript Support
Add type declarations for className support in nativewind-env.d.ts:
/// <reference types="nativewind/types" />This extends React Native core components to accept the className prop.
Performance Patterns
List Optimization
FlatList Tuning
<FlatList
data={items}
renderItem={renderItem}
keyExtractor={(item) => item.id}
removeClippedSubviews
maxToRenderPerBatch={10}
windowSize={5}
initialNumToRender={10}
getItemLayout={(_data, index) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
})}
/>| Prop | Effect |
|---|---|
removeClippedSubviews | Unmounts offscreen views (Android benefit) |
maxToRenderPerBatch | Items rendered per scroll batch |
windowSize | Viewport multiplier for render window |
initialNumToRender | Items in first render pass |
getItemLayout | Skips measurement for fixed-height items |
FlashList for Large Lists
import { FlashList } from '@shopify/flash-list';
<FlashList
data={items}
renderItem={renderItem}
estimatedItemSize={80}
keyExtractor={(item) => item.id}
/>;FlashList uses cell recycling instead of unmounting/remounting, providing better performance for lists over 100 items.
Memoization
React.memo for List Items
import { memo } from 'react';
const ListItem = memo(function ListItem({ item, onPress }: ListItemProps) {
return (
<Pressable onPress={() => onPress(item.id)}>
<Text>{item.title}</Text>
</Pressable>
);
});useCallback for Stable References
function ItemList({ items }: { items: Item[] }) {
const handlePress = useCallback((id: string) => {
router.push(`/item/${id}`);
}, []);
const renderItem: ListRenderItem<Item> = useCallback(
({ item }) => <ListItem item={item} onPress={handlePress} />,
[handlePress],
);
return (
<FlatList
data={items}
renderItem={renderItem}
keyExtractor={(item) => item.id}
/>
);
}Animations
Reanimated Worklets
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
} from 'react-native-reanimated';
function AnimatedCard() {
const scale = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
const handlePressIn = () => {
scale.value = withSpring(0.95);
};
const handlePressOut = () => {
scale.value = withSpring(1);
};
return (
<Pressable onPressIn={handlePressIn} onPressOut={handlePressOut}>
<Animated.View style={[styles.card, animatedStyle]}>
<Text>Animated Card</Text>
</Animated.View>
</Pressable>
);
}Layout Animations
import Animated, {
FadeIn,
FadeOut,
LinearTransition,
} from 'react-native-reanimated';
function AnimatedList({ items }: { items: Item[] }) {
return (
<Animated.FlatList
data={items}
itemLayoutAnimation={LinearTransition}
renderItem={({ item }) => (
<Animated.View entering={FadeIn} exiting={FadeOut}>
<Text>{item.title}</Text>
</Animated.View>
)}
keyExtractor={(item) => item.id}
/>
);
}Gesture Handler + Reanimated
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
} from 'react-native-reanimated';
function DraggableBox() {
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const pan = Gesture.Pan()
.onUpdate((event) => {
translateX.value = event.translationX;
translateY.value = event.translationY;
})
.onEnd(() => {
translateX.value = withSpring(0);
translateY.value = withSpring(0);
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: translateX.value },
{ translateY: translateY.value },
],
}));
return (
<GestureDetector gesture={pan}>
<Animated.View style={[styles.box, animatedStyle]} />
</GestureDetector>
);
}Image Optimization
import { Image } from 'expo-image';
<Image
source={{ uri: imageUrl }}
style={{ width: 300, height: 200 }}
contentFit="cover"
placeholder={{ blurhash: 'LGF5]+Yk^6#M@-5c,1J5@[or[Q6.' }}
cachePolicy="memory-disk"
transition={200}
recyclingKey={imageUrl}
/>;| Prop | Purpose |
|---|---|
cachePolicy | memory, disk, memory-disk, none |
placeholder | Blurhash/thumbhash while loading |
recyclingKey | Prevents flash when recycled in lists |
transition | Fade-in duration in milliseconds |
Bundle Size
Tree Shaking with Expo
Import only what you use from large packages:
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import Ionicons from '@expo/vector-icons/Ionicons';Lazy Loading Screens
Expo Router supports lazy loading by default. Heavy screens load on navigation:
import { Stack } from 'expo-router';
<Stack>
<Stack.Screen name="heavy-screen" options={{ lazy: true }} />
</Stack>;Hermes Engine
Hermes is enabled by default in Expo. It provides faster startup, lower memory usage, and smaller bundle size compared to JSC.
Verify Hermes is active:
const isHermes = () => !!global.HermesInternal;Profiling
React DevTools Profiler
npx react-devtoolsConnect to the dev client for component render profiling.
Flipper Integration
For development builds, Flipper provides network inspection, layout inspection, and performance monitoring. Install via expo-dev-client.
Performance Monitoring Checklist
| Area | Target | Tool |
|---|---|---|
| JS thread FPS | 60 fps | React DevTools |
| UI thread FPS | 60 fps | Systrace/Instruments |
| Bundle size | Minimize | npx expo export |
| Memory | No leaks | Xcode Instruments / Android Profiler |
| Startup time | < 2s | Hermes + lazy loading |