
React Native Dev
- 8 installs
- 33 repo stars
- Updated April 26, 2026
- bighardperson/computer-science-skills-collection
This is a copy of react-native-dev by minimax-ai - installs and ranking accrue to the original listing.
react-native-dev is a Claude skill that guides building production React Native and Expo mobile apps across UI, state, navigation, testing, and deployment.
About
This skill is a guide for building production React Native and Expo apps. A developer uses it when implementing mobile UI, navigation, state, animations, forms, networking, or deploying to the App Store and Play Store. It points to reference files and prescribes current component and library choices.
- Practical React Native and Expo guide spanning UI, navigation, state, forms, networking, and testing
- Recommends modern defaults: FlashList over FlatList, expo-image over Image, Reanimated 3, Expo Router
- Covers EAS build/submit, CI/CD, SDK upgrades, and performance profiling with 12 reference files
React Native Dev by the numbers
- 8 all-time installs (skills.sh)
- Data as of Jul 30, 2026 (Skillselion catalog sync)
react-native-dev capabilities & compatibility
Free (MIT-licensed guide); uses open-source RN/Expo tooling
- Capabilities
- frontend · ui design · testing
- Use cases
- frontend · ui design · testing
- Pricing
- Free
What react-native-dev says it does
A practical guide for building production-ready React Native and Expo applications. Covers UI, animations, state, testing, performance, and deployment.
npx skills add https://github.com/bighardperson/computer-science-skills-collection --skill react-native-devAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 8 |
|---|---|
| repo stars | ★ 33 |
| Last updated | April 26, 2026 |
| Repository | bighardperson/computer-science-skills-collection ↗ |
What it does
Build production React Native and Expo apps covering UI, state, navigation, testing, and store deployment.
Who is it for?
Developers building or scaling React Native/Expo apps who want current library defaults and pitfalls.
When should I use this skill?
Building a React Native or Expo app, implementing native UI, managing state, or deploying to app stores.
What you get
A React Native/Expo app built with current recommended components, state, navigation, and deployment patterns.
- React Native / Expo app screens and components
- Navigation and state setup
- EAS build/submit configuration
By the numbers
- 12 reference files (navigation, components, styling, animations, state, forms, networking, performance, testing, native,
Files
React Native & Expo Development Guide
A practical guide for building production-ready React Native and Expo applications. Covers UI, animations, state, testing, performance, and deployment.
References
Consult these resources as needed:
- references/navigation.md — Expo Router: Stack, Tabs, NativeTabs (
headerLargeTitle,headerBackButtonDisplayMode), links, modals, sheets, context menus - references/components.md — FlashList patterns,
expo-image, safe areas (contentInsetAdjustmentBehavior), native controls, blur/glass effects, storage - references/styling.md — StyleSheet, NativeWind/Tailwind, platform styles, theming, dark mode
- references/animations.md — Reanimated 3: entering/exiting, shared values, gestures, scroll-driven
- references/state-management.md — Zustand (selectors, persist), Jotai (atoms, derived), React Query, Context
- references/forms.md — React Hook Form + Zod: validation, multi-step, dynamic arrays
- references/networking.md — fetch wrapper, React Query (optimistic updates), auth tokens, offline, API routes, webhooks
- references/performance.md — Profiling workflow, FlashList +
memo, bundle analysis, TTI, memory leaks, animation perf - references/testing.md — Jest, React Native Testing Library, E2E with Maestro
- references/native-capabilities.md — Camera, location, permissions (
use*Permissionshooks), haptics, notifications, biometrics - references/engineering.md — Project layout (
components/ui/,stores/,services/), path aliases, SDK upgrades, EAS build/submit, CI/CD, DOM components
Quick Reference
Component Preferences
| Purpose | Use | Instead of |
|---|---|---|
| Lists | FlashList (@shopify/flash-list) + memo items | FlatList (no view recycling) |
| Images | expo-image | RN <Image> (no cache, no WebP) |
| Press | Pressable | TouchableOpacity (legacy) |
| Audio | expo-audio | expo-av (deprecated) |
| Video | expo-video | expo-av (deprecated) |
| Animations | Reanimated 3 | RN Animated API (limited) |
| Gestures | Gesture Handler | PanResponder (legacy) |
| Platform check | process.env.EXPO_OS | Platform.OS |
| Context | React.use() | React.useContext() (React 18) |
| Safe area scroll | contentInsetAdjustmentBehavior="automatic" | <SafeAreaView> |
| SF Symbols | expo-image with source="sf:name" | expo-symbols |
Scaling Up
| Situation | Consider |
|---|---|
| Long lists with scroll jank | Virtualized list libraries (e.g. FlashList) |
| Want Tailwind-style classes | NativeWind v4 |
| High-frequency storage reads | Sync-based storage (e.g. MMKV) |
| New project with Expo | Expo Router over bare React Navigation |
State Management
| State Type | Solution |
|---|---|
| Local UI state | useState / useReducer |
| Shared app state | Zustand or Jotai |
| Server / async data | React Query |
| Form state | React Hook Form + Zod |
Performance Priorities
| Priority | Issue | Fix |
|---|---|---|
| CRITICAL | Long list jank | FlashList + memoized items |
| CRITICAL | Large bundle | Avoid barrel imports, enable R8 |
| HIGH | Too many re-renders | Zustand selectors, React Compiler |
| HIGH | Slow startup | Disable bundle compression, native nav |
| MEDIUM | Animation drops | Only animate transform/opacity |
New Project Init
# 1. Create project
npx create-expo-app@latest my-app --template blank-typescript
cd my-app
# 2. Install Expo Router + core deps
npx expo install expo-router react-native-safe-area-context react-native-screens
# 3. (Optional) Common extras
npx expo install expo-image react-native-reanimated react-native-gesture-handlerThen configure:
1. Set entry point in package.json: "main": "expo-router/entry" 2. Add scheme in app.json: "scheme": "my-app" 3. Delete App.tsx and index.ts 4. Create app/_layout.tsx as root Stack layout 5. Create app/(tabs)/_layout.tsx for tab navigation 6. Create route files in app/(tabs)/ (see navigation.md)
For web support, also install: npx expo install react-native-web react-dom @expo/metro-runtime
Core Principles
Consult references before writing: when implementing navigation, lists, networking, or project setup, read the matching reference file above for patterns and pitfalls.
Try Expo Go first (npx expo start). Custom builds (eas build) only needed when using local Expo modules, Apple targets, or third-party native modules not in Expo Go.
Conditional rendering: use {count > 0 && <Text />} not {count && <Text />} (renders "0").
Animation rule: only animate transform and opacity — GPU-composited, no layout thrash.
Imports: always import directly from source, not barrel files — avoids bundle bloat.
Lists and images: before using FlatList or RN Image, check the Component Preferences table above — FlashList and expo-image are almost always the right choice.
Route files: always use kebab-case, never co-locate components/types/utils in app/.
Checklist
New Project Setup
- [ ]
tsconfig.jsonpath aliases configured - [ ]
EXPO_PUBLIC_API_URLenv var set per environment - [ ] Root layout has
GestureHandlerRootView(if using gestures) - [ ]
contentInsetAdjustmentBehavior="automatic"on all scroll views - [ ]
FlashListinstead ofFlatListfor lists > 20 items
Before Shipping
- [ ] Profile in
--profilemode, fix frames > 16ms - [ ] Bundle analyzed (
source-map-explorer), no barrel imports - [ ] R8 enabled for Android
- [ ] Unit + component tests for critical paths
- [ ] E2E flows for login, core feature, checkout
---
Flutter development → see flutter-dev skill. iOS native (UIKit/SwiftUI) → see ios-application-dev skill. Android native (Kotlin/Compose) → see android-native-dev skill.
React Native is a trademark of Meta Platforms, Inc. Expo is a trademark of 650 Industries, Inc. All other product names are trademarks of their respective owners.
{
"name": "React Native 开发",
"installedAt": 1776152084588,
"source": "marketplace",
"iconSource": "react-native-dev"
}Animations Reference
Reanimated 3 animations, gestures, and transitions for Expo/React Native.
Core Rules
- Only animate `transform` and `opacity` — GPU-composited, no layout recalculation
- Use
useDerivedValuefor computed animated values, not inline JS expressions - Use
Gesture.Tapinstead ofPressableinsideGestureDetector - All Reanimated callbacks run as worklets on the UI thread — no async/await
Setup
npx expo install react-native-reanimated react-native-gesture-handler// babel.config.js
module.exports = { presets: ["babel-preset-expo"], plugins: ["react-native-reanimated/plugin"] };// app/_layout.tsx — wrap root in GestureHandlerRootView
import { GestureHandlerRootView } from "react-native-gesture-handler";
export default function RootLayout() {
return <GestureHandlerRootView style={{ flex: 1 }}><Stack /></GestureHandlerRootView>;
}Entering / Exiting Animations
import Animated, {
FadeIn, FadeOut,
SlideInRight, SlideOutLeft,
ZoomIn, ZoomOut,
BounceIn,
} from "react-native-reanimated";
// Basic
<Animated.View entering={FadeIn} exiting={FadeOut}>
<Text>Content</Text>
</Animated.View>
// With options
<Animated.View
entering={FadeIn.duration(300).delay(100)}
exiting={SlideOutLeft.duration(200)}
/>
// Spring-based
<Animated.View entering={ZoomIn.springify().damping(15)} />Built-in Presets
| Category | Entering | Exiting |
|---|---|---|
| Fade | FadeIn, FadeInUp, FadeInDown, FadeInLeft, FadeInRight | FadeOut* |
| Slide | SlideInUp, SlideInDown, SlideInLeft, SlideInRight | SlideOut* |
| Zoom | ZoomIn, ZoomInUp, ZoomInDown | ZoomOut* |
| Bounce | BounceIn, BounceInUp, BounceInDown | BounceOut* |
| Flip | FlipInXUp, FlipInYLeft | FlipOut* |
| Roll | RollInLeft, RollInRight | RollOut* |
| Stretch | StretchInX, StretchInY | StretchOut* |
| Pinwheel | PinwheelIn | PinwheelOut |
| Rotate | RotateInDownLeft | RotateOut* |
| LightSpeed | LightSpeedInLeft | LightSpeedOut* |
Shared Values & useAnimatedStyle
import {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
withRepeat,
withSequence,
Easing,
} from "react-native-reanimated";
const offset = useSharedValue(0);
const opacity = useSharedValue(1);
const animStyle = useAnimatedStyle(() => ({
transform: [{ translateX: offset.value }],
opacity: opacity.value,
}));
// Animate
offset.value = withSpring(100);
opacity.value = withTiming(0, { duration: 300, easing: Easing.out(Easing.quad) });
// Repeat
opacity.value = withRepeat(withTiming(0.3, { duration: 800 }), -1, true);
// Sequence
offset.value = withSequence(
withTiming(-10, { duration: 100 }),
withTiming(10, { duration: 100 }),
withSpring(0),
);
<Animated.View style={animStyle} />useDerivedValue
import { useDerivedValue } from "react-native-reanimated";
const progress = useSharedValue(0); // 0–1
const rotation = useDerivedValue(() => `${progress.value * 360}deg`);
const scale = useDerivedValue(() => 0.5 + progress.value * 0.5);
const animStyle = useAnimatedStyle(() => ({
transform: [{ rotate: rotation.value }, { scale: scale.value }],
}));Layout Animations
import { Layout, LinearTransition, CurvedTransition } from "react-native-reanimated";
// Item reorder/add/remove animation
<Animated.View layout={LinearTransition}>
{/* Content that changes size/position */}
</Animated.View>
// Spring layout transition
<Animated.View layout={LinearTransition.springify()}>Gestures
import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { useSharedValue, useAnimatedStyle, withSpring } from "react-native-reanimated";
// Pan gesture
const offsetX = useSharedValue(0);
const offsetY = useSharedValue(0);
const panGesture = Gesture.Pan()
.onUpdate((e) => {
offsetX.value = e.translationX;
offsetY.value = e.translationY;
})
.onEnd(() => {
offsetX.value = withSpring(0);
offsetY.value = withSpring(0);
});
const animStyle = useAnimatedStyle(() => ({
transform: [{ translateX: offsetX.value }, { translateY: offsetY.value }],
}));
<GestureDetector gesture={panGesture}>
<Animated.View style={animStyle} />
</GestureDetector>// Tap gesture (use instead of Pressable inside GestureDetector)
const tapGesture = Gesture.Tap()
.numberOfTaps(1)
.onEnd(() => {
scale.value = withSequence(withTiming(0.95), withSpring(1));
});
// Pinch gesture
const baseScale = useSharedValue(1);
const savedScale = useSharedValue(1);
const pinchGesture = Gesture.Pinch()
.onUpdate((e) => { baseScale.value = savedScale.value * e.scale; })
.onEnd(() => { savedScale.value = baseScale.value; });
// Composed gestures
const composed = Gesture.Simultaneous(panGesture, pinchGesture);
const exclusive = Gesture.Exclusive(tapGesture, panGesture);Scroll-Driven Animations
import Animated, {
useAnimatedScrollHandler,
useSharedValue,
interpolate,
Extrapolation,
} from "react-native-reanimated";
const scrollY = useSharedValue(0);
const scrollHandler = useAnimatedScrollHandler((e) => {
scrollY.value = e.contentOffset.y;
});
// Parallax header
const headerStyle = useAnimatedStyle(() => ({
transform: [{
translateY: interpolate(scrollY.value, [0, 200], [0, -100], Extrapolation.CLAMP),
}],
opacity: interpolate(scrollY.value, [0, 200], [1, 0], Extrapolation.CLAMP),
}));
<Animated.ScrollView onScroll={scrollHandler} scrollEventThrottle={16}>
<Animated.View style={headerStyle}>
<Text>Parallax Header</Text>
</Animated.View>
</Animated.ScrollView>Zoom Transitions (Expo Router, iOS 18+)
import { Link } from "expo-router";
<Link href="/detail" asChild>
<Link.AppleZoom>
<Pressable>
<Image source={thumbnail} />
</Pressable>
</Link.AppleZoom>
</Link>Adding Animations to State Changes
// ✓ Always add entering/exiting for state-driven UI changes
{isVisible && (
<Animated.View entering={FadeIn.duration(200)} exiting={FadeOut.duration(150)}>
<Toast message={message} />
</Animated.View>
)}
// ✓ AnimatedFlatList for list item changes
import Animated from "react-native-reanimated";
const AnimatedFlashList = Animated.createAnimatedComponent(FlashList);Common Mistakes
| Wrong | Right |
|---|---|
Animate width/height | Animate transform: scaleX/scaleY |
Inline JS math in useAnimatedStyle | useDerivedValue for computations |
Pressable inside GestureDetector | Gesture.Tap() |
async in worklet | Run async outside, update sharedValue in callback |
Frequent console.log in worklet | console.log works but serializes to JS thread — use sparingly in hot paths |
Components Reference
Native UI components, media, visual effects, and storage patterns for Expo/React Native.
Images
import { Image } from "expo-image";
// Always use expo-image — not React Native's built-in Image
<Image
source={{ uri: "https://example.com/photo.jpg" }}
style={{ width: 200, height: 200 }}
contentFit="cover"
transition={300}
placeholder={blurhash}
/>Lists
import { FlashList } from "@shopify/flash-list";
import { memo } from "react";
const Item = memo(({ title }: { title: string }) => (
<View style={styles.item}><Text>{title}</Text></View>
));
<FlashList
data={items}
renderItem={({ item }) => <Item title={item.title} />}
keyExtractor={(item) => item.id}
estimatedItemSize={80}
/>Safe Areas
import { useSafeAreaInsets } from "react-native-safe-area-context";
// With ScrollView
<ScrollView contentInsetAdjustmentBehavior="automatic">
{/* content */}
</ScrollView>
// Manual insets
const insets = useSafeAreaInsets();
<View style={{ paddingBottom: insets.bottom }} />Native Controls (iOS)
import { Switch } from "react-native";
import SegmentedControl from "@react-native-segmented-control/segmented-control";
// Switch
<Switch value={enabled} onValueChange={setEnabled} />
// Segmented Control
<SegmentedControl
values={["Day", "Week", "Month"]}
selectedIndex={selectedIndex}
onChange={(e) => setSelectedIndex(e.nativeEvent.selectedSegmentIndex)}
/>Form Sheets (Bottom Sheet)
// app/modal.tsx
import { Stack } from "expo-router";
<Stack.Screen options={{
presentation: "formSheet",
sheetAllowedDetents: [0.5, 1.0],
sheetGrabberVisible: true,
}} />Visual Effects
import { BlurView } from "expo-blur";
<BlurView intensity={80} tint="light" style={StyleSheet.absoluteFill} />
// Liquid glass (iOS 26+, New Architecture only)
import { GlassEffect } from "expo-glass-effect";
<GlassEffect style={{ borderRadius: 16, padding: 20 }} />Search
// Using expo-router search bar (iOS only)
import { useNavigation } from "expo-router";
useEffect(() => {
navigation.setOptions({
headerSearchBarOptions: {
placeholder: "Search...",
onChangeText: (e) => setQuery(e.nativeEvent.text),
},
});
}, [navigation]);Storage
| Need | Solution |
|---|---|
| Structured data | expo-sqlite |
| Simple key-value | @react-native-async-storage/async-storage |
| Sensitive data | expo-secure-store |
Media
import { CameraView, useCameraPermissions } from "expo-camera";
import { useAudioPlayer } from "expo-audio";
import { useVideoPlayer, VideoView } from "expo-video";
import * as ImagePicker from "expo-image-picker";Engineering Reference
Project structure, tooling, builds, releases, and platform integration for Expo / React Native.
Project Structure
Standard Layout
my-app/
app/ File-based routing (Expo Router)
_layout.tsx Root layout: providers, fonts, NativeTabs
index.tsx → /
(tabs)/
_layout.tsx Tab navigator
home.tsx → /home
profile.tsx → /profile
(auth)/
login.tsx → /login (group, not in URL)
register.tsx → /register
user/
[id].tsx → /user/:id
[id]/
posts.tsx → /user/:id/posts
api/
users+api.ts → /api/users (server route)
users/[id]+api.ts → /api/users/:id
components/ Reusable UI components
ui/ Primitive components (Button, Input, Card)
shared/ Composed components (UserAvatar, PostCard)
hooks/ Custom React hooks
stores/ Zustand / Jotai stores
services/ API client, external service wrappers
utils/ Pure utility functions
constants/ App-wide constants (colors, spacing, config)
types/ Shared TypeScript types/interfaces
assets/ Static assets (images, fonts, icons)
scripts/ Build/dev helper scripts
app.json Expo config
eas.json EAS Build config
tsconfig.json TypeScript config with path aliases
.env Environment variables
.env.development
.env.productionRoute Conventions
| File | Route | Notes |
|---|---|---|
app/index.tsx | / | Home/root |
app/about.tsx | /about | Static route |
app/user/[id].tsx | /user/:id | Dynamic segment |
app/user/[...rest].tsx | /user/* | Catch-all |
app/(tabs)/home.tsx | /home | Group (not in URL) |
app/(a,b)/shared.tsx | Shared between tabs a and b | Multi-group |
app/_layout.tsx | Layout wrapper | No route |
app/+not-found.tsx | 404 page | |
app/api/users+api.ts | /api/users | Server route |
Rules:
- Routes only in
app/— no components, types, or utils - Always have a route matching
/ - Use kebab-case filenames (
user-profile.tsx, notUserProfile.tsx) - Remove old route files when restructuring
Path Aliases
// tsconfig.json
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": ["./*"],
"@components/*": ["./components/*"],
"@hooks/*": ["./hooks/*"],
"@stores/*": ["./stores/*"],
"@services/*": ["./services/*"],
"@utils/*": ["./utils/*"],
"@constants/*": ["./constants/*"],
"@types/*": ["./types/*"]
}
}
}// ✗ Relative imports — fragile, change with file moves
import { Button } from "../../../components/ui/Button";
// ✓ Alias imports — stable
import { Button } from "@components/ui/Button";Metro resolves paths and baseUrl from tsconfig.json natively — no extra config needed. If using a non-Metro bundler, install babel-plugin-module-resolver:
// babel.config.js — only needed for non-Metro bundlers
module.exports = {
presets: ["babel-preset-expo"],
plugins: [
["module-resolver", {
root: ["./"],
alias: {
"@": "./",
"@components": "./components",
"@hooks": "./hooks",
"@stores": "./stores",
"@services": "./services",
},
}],
],
};Components Organization
components/
ui/ Atomic components
Button.tsx
Input.tsx
Card.tsx
Badge.tsx
index.ts Barrel export
shared/ Composed components
UserAvatar.tsx
PostCard.tsx
EmptyState.tsx
layout/ Layout components
Screen.tsx SafeArea wrapper
Header.tsx// components/ui/index.ts — barrel export
export { Button } from "./Button";
export { Input } from "./Input";
export { Card } from "./Card";
// Usage
import { Button, Input, Card } from "@components/ui";Design System
constants/
colors.ts Color palette + semantic colors
spacing.ts 8pt grid spacing values
typography.ts Font families, sizes, weights
theme.ts Combined theme object// constants/colors.ts
export const colors = {
primary: "#6200EE",
secondary: "#03DAC6",
background: "#FFFFFF",
surface: "#F5F5F5",
error: "#B00020",
text: { primary: "#000000DE", secondary: "#0000008A" },
} as const;
// constants/spacing.ts — 8pt grid
export const spacing = {
xs: 4, sm: 8, md: 16, lg: 24, xl: 32, xxl: 48,
} as const;
// constants/typography.ts
export const typography = {
sizes: { xs: 12, sm: 14, md: 16, lg: 20, xl: 24, xxl: 32 },
weights: { regular: "400", medium: "500", semibold: "600", bold: "700" },
} as const;Services Layer
services/
api/
client.ts Base fetch client with auth headers
users.ts User-related API calls
posts.ts Post-related API calls
storage/
secure-store.ts Wrapper for expo-secure-store
async-storage.ts Wrapper for AsyncStorage
notifications/
push.ts Expo push notification helpers// services/api/client.ts
const BASE_URL = process.env.EXPO_PUBLIC_API_URL!;
export const api = {
get: <T,>(path: string, token?: string) =>
fetch(`${BASE_URL}${path}`, {
headers: { Authorization: token ? `Bearer ${token}` : "" },
}).then(async (r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json() as Promise<T>;
}),
// post/put/delete follow same pattern — add method, Content-Type, JSON.stringify(body)
};Monorepo
my-monorepo/
apps/
mobile/ Expo app (all native deps here)
package.json
app.json
web/ Next.js app
package.json
packages/
ui/ Shared UI components (no native deps)
package.json
utils/ Shared utilities (no native deps)
package.json
types/ Shared TypeScript types
package.json
package.json Root workspace config// Root package.json
{
"private": true,
"workspaces": ["apps/*", "packages/*"],
"scripts": {
"mobile": "yarn workspace @my/mobile start",
"web": "yarn workspace @my/web dev"
}
}Monorepo rules:
- Keep native dependencies in the app package (
apps/mobile) — never in shared packages - Use a single version of each dependency across all packages
- Shared packages should be pure JS/TS only
Environment Variables
# .env (committed, non-sensitive defaults)
EXPO_PUBLIC_APP_NAME=MyApp
EXPO_PUBLIC_API_VERSION=v1
# .env.development (local only, gitignored)
EXPO_PUBLIC_API_URL=http://localhost:3000
# .env.production (CI/CD only, gitignored)
EXPO_PUBLIC_API_URL=https://api.production.example.com// types/env.d.ts
declare global {
namespace NodeJS {
interface ProcessEnv {
EXPO_PUBLIC_API_URL: string;
EXPO_PUBLIC_APP_NAME: string;
}
}
}
export {};Custom Fonts
npx expo install expo-font// app.json — config plugin (preferred over manual linking)
{
"expo": {
"plugins": [
["expo-font", { "fonts": ["./assets/fonts/Inter-Regular.ttf"] }]
]
}
}// app/_layout.tsx
import { useFonts } from "expo-font";
import { SplashScreen } from "expo-router";
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const [loaded] = useFonts({ "Inter-Regular": require("../assets/fonts/Inter-Regular.ttf") });
useEffect(() => { if (loaded) SplashScreen.hideAsync(); }, [loaded]);
if (!loaded) return null;
return <Stack />;
}Development Builds
Expo Go (npx expo start) covers most use cases out of the box. Switch to a custom dev client when your project uses native code that Expo Go doesn't bundle — for example, a local Expo module in modules/, an Apple target (widget, app clip), or a community native library that isn't pre-installed in Expo Go.
Creating a Dev Client
# Option A — cloud build, push to TestFlight / internal distribution
eas build -p ios --profile development --submit
# Option B — build locally (requires Xcode / Android Studio)
eas build -p ios --profile development --localAfter installing on the device or simulator, connect with:
npx expo start --dev-clienteas.json Profile
{
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"autoIncrement": true
}
}
}Upgrading the SDK
Routine Upgrade
npx expo install expo@latest --fix # bumps Expo + aligns peer deps
npx expo-doctor # surfaces remaining mismatchesThen test on both platforms and rebuild the dev client if you use one.
Trying a Pre-release
Pre-release versions are tagged @next on npm:
npx expo install expo@next --fixNotable Changes Across SDK Versions
| Version | What Changed |
|---|---|
| SDK 53 | New Architecture on by default; Expo Go requires it; autoprefixer no longer needed |
| SDK 54 | React 19 (use() replaces useContext, <Context> replaces <Context.Provider>, forwardRef removed); React Compiler available; EXPO_USE_FAST_RESOLVER removed |
| SDK 55 | NativeTabs API updated — Icon/Label/Badge accessed via NativeTabs.Trigger.* |
| Ongoing | expo-av deprecated in favor of expo-audio + expo-video |
React 19 Patterns (SDK 54+)
// Context
import { use, createContext } from "react";
const ThemeCtx = createContext("light");
// consume: const theme = use(ThemeCtx);
// provide: <ThemeCtx value="dark">...</ThemeCtx>
// Refs — no more forwardRef
function Field({ ref, ...props }: Props & { ref?: React.Ref<TextInput> }) {
return <TextInput ref={ref} {...props} />;
}Opting Out of New Architecture
If a third-party library breaks under the New Architecture:
{ "expo": { "newArchEnabled": false } }Check compatibility at reactnative.directory.
Releasing
Build Profiles
A typical eas.json has three tiers:
{
"cli": { "version": ">= 16.0.1", "appVersionSource": "remote" },
"build": {
"development": { "developmentClient": true, "distribution": "internal", "autoIncrement": true },
"preview": { "distribution": "internal", "autoIncrement": true },
"production": { "autoIncrement": true, "ios": { "resourceClass": "m-medium" } }
}
}Building & Submitting
# Build for both platforms
eas build -p ios --profile production
eas build -p android --profile production
# Build + submit in one step
eas build -p ios --profile production --submit
# Or submit a finished build separately
eas submit -p ios
eas submit -p androidStore Submission Notes
iOS — Run eas credentials to set up signing. Create the app record in App Store Connect, fill metadata, then --submit pushes the build to TestFlight automatically.
Android — Create a Google Play service account, download its JSON key, and reference it in eas.json under submit.production.android.serviceAccountKeyPath. The first build must be uploaded manually through Play Console; subsequent builds use eas submit.
Over-the-Air Updates
For JS-only changes (no new native code), skip the full build/review cycle:
npx expo install expo-updates
eas update --branch production --message "Fix checkout rounding error"Web Hosting
npx expo export -p web
eas deploy # preview URL
eas deploy --prod # productionCI/CD with EAS Workflows
Workflow files live in .eas/workflows/ and follow a YAML schema:
# .eas/workflows/release.yml
name: Release to stores
on:
push:
branches: [main]
jobs:
build:
type: build
params:
platform: all
profile: production
submit:
type: submit
needs: [build]
params:
platform: all
profile: production# .eas/workflows/pr-check.yml
name: PR check
on:
pull_request:
branches: [main]
jobs:
preview-build:
type: build
params:
platform: all
profile: previewDOM Components
The "use dom" directive lets you render web-only code inside a WebView on native while running it as standard DOM on web. Useful for libraries that depend on browser APIs (chart libraries, rich text editors, syntax highlighters).
// components/RichPreview.tsx
"use dom";
import ReactMarkdown from "react-markdown";
export default function RichPreview({ markdown }: { markdown: string }) {
return <ReactMarkdown>{markdown}</ReactMarkdown>;
}// app/note/[id].tsx — native screen
import RichPreview from "@/components/RichPreview";
export default function NoteScreen() {
const { content } = useNote();
return (
<ScrollView>
<RichPreview markdown={content} />
</ScrollView>
);
}Rules:
"use dom"must be the first statement in the file- One default export per file; cannot be mixed with native components
- Props must be serializable (strings, numbers, booleans, plain objects/arrays)
- Async function props bridge native actions into the webview (e.g.,
onSave: (data) => Promise<void>) - Cannot be used in
_layout.tsxfiles - Router hooks that read native navigation state (
useLocalSearchParams,usePathname, etc.) must be called in the native parent and passed as props
Forms Reference
React Hook Form + Zod validation for React Native / Expo.
Setup
npx expo install react-hook-form zod @hookform/resolversBasic Form
import { useForm, Controller } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
const schema = z.object({
email: z.string().email("Invalid email"),
password: z.string().min(8, "Min 8 characters"),
});
type FormData = z.infer<typeof schema>;
export function LoginForm({ onSubmit }: { onSubmit: (data: FormData) => void }) {
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { email: "", password: "" },
});
return (
<View>
{/* Controller pattern — repeat for each field */}
<Controller
control={control}
name="email"
render={({ field: { onChange, onBlur, value } }) => (
<TextInput value={value} onChangeText={onChange} onBlur={onBlur}
placeholder="Email" keyboardType="email-address" autoCapitalize="none" />
)}
/>
{errors.email && <Text style={styles.error}>{errors.email.message}</Text>}
{/* Same Controller pattern for password, with secureTextEntry */}
<Pressable onPress={handleSubmit(onSubmit)} disabled={isSubmitting}>
<Text>{isSubmitting ? "Submitting..." : "Login"}</Text>
</Pressable>
</View>
);
}Zod Schema Patterns
import { z } from "zod";
// Registration form
const registerSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters").max(50),
email: z.string().email("Invalid email address"),
password: z.string()
.min(8, "At least 8 characters")
.regex(/[A-Z]/, "Must contain uppercase letter")
.regex(/[0-9]/, "Must contain a number"),
confirmPassword: z.string(),
age: z.number({ invalid_type_error: "Age must be a number" }).int().min(18, "Must be 18+").optional(),
role: z.enum(["admin", "user", "guest"]),
agreedToTerms: z.literal(true, { errorMap: () => ({ message: "Must agree to terms" }) }),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
});
// All-optional schema — use .optional() or .partial()
const profileSchema = registerSchema.pick({ name: true, email: true }).partial();
// Nested objects — compose schemas with z.array() and references
const addressSchema = z.object({ street: z.string().min(1), city: z.string().min(1), country: z.string().length(2) });
const orderSchema = z.object({ items: z.array(z.object({ productId: z.string(), quantity: z.number().int().positive() })).min(1), shippingAddress: addressSchema });Form State
const {
control,
handleSubmit,
watch,
setValue,
getValues,
reset,
setError,
clearErrors,
formState: {
errors,
isSubmitting,
isValid,
isDirty, // Any field changed from defaultValues
dirtyFields, // Which fields changed
touchedFields, // Which fields were focused
},
} = useForm<FormData>({ resolver: zodResolver(schema) });
// Watch a field value
const password = watch("password");
const allValues = watch(); // Watch all
// Set a value programmatically
setValue("email", "prefilled@example.com", { shouldValidate: true });
// Reset form
reset(); // Back to defaultValues
reset({ email: "new@email.com" }); // Reset with new values
// Set server-side errors
setError("email", { message: "Email already in use" });Async Submit with Error Handling
const { handleSubmit, setError } = useForm<FormData>();
const onSubmit = async (data: FormData) => {
try {
await api.post("/auth/register", data);
router.replace("/home");
} catch (error) {
if (error instanceof ApiError && error.status === 409) {
setError("email", { message: "Email already registered" });
} else {
setError("root", { message: "Something went wrong. Please try again." });
}
}
};
// Display root error
{errors.root && <Text style={styles.rootError}>{errors.root.message}</Text>}Multi-Step Forms
const schema = z.object({
step1: z.object({ name: z.string().min(1), email: z.string().email() }),
step2: z.object({ phone: z.string(), address: z.string() }),
step3: z.object({ password: z.string().min(8), confirmPassword: z.string() }),
});
type FormData = z.infer<typeof schema>;
export function MultiStepForm() {
const [step, setStep] = useState(1);
const { control, handleSubmit, trigger, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
});
const nextStep = async () => {
const stepKey = `step${step}` as keyof FormData;
const valid = await trigger(stepKey); // Validate only current step's fields
if (valid) setStep(s => s + 1);
};
// Render step component by index, with Back/Next/Submit navigation
// Key pattern: trigger(stepKey) validates only current step before advancing
return (/* StepOne | StepTwo | StepThree + Back/Next/Submit buttons */);
}Reusable Field Components
// components/ui/FormField.tsx
import { Controller, Control, FieldValues, Path } from "react-hook-form";
interface FormFieldProps<T extends FieldValues> {
control: Control<T>;
name: Path<T>;
label: string;
placeholder?: string;
secureTextEntry?: boolean;
keyboardType?: TextInputProps["keyboardType"];
}
export function FormField<T extends FieldValues>({
control, name, label, placeholder, secureTextEntry, keyboardType,
}: FormFieldProps<T>) {
// Wraps Controller with: label, styled TextInput, and error message display
// Uses fieldState.error for per-field error, accessibilityLabel for a11y
return (
<Controller control={control} name={name}
render={({ field: { onChange, onBlur, value }, fieldState: { error } }) => (
<View>
<Text>{label}</Text>
<TextInput value={value} onChangeText={onChange} onBlur={onBlur}
placeholder={placeholder} secureTextEntry={secureTextEntry} keyboardType={keyboardType}
style={[styles.input, error && styles.inputError]} accessibilityLabel={label} />
{error && <Text style={styles.errorText}>{error.message}</Text>}
</View>
)}
/>
);
}
// Usage
<FormField control={control} name="email" label="Email" keyboardType="email-address" />
<FormField control={control} name="password" label="Password" secureTextEntry />Dynamic Arrays
import { useFieldArray } from "react-hook-form";
const schema = z.object({
tags: z.array(z.object({ value: z.string().min(1) })).min(1, "Add at least one tag"),
});
function TagsForm() {
const { control, handleSubmit } = useForm<z.infer<typeof schema>>();
const { fields, append, remove } = useFieldArray({ control, name: "tags" });
return (
<View>
{fields.map((field, index) => (
<View key={field.id} style={{ flexDirection: "row" }}>
<Controller
control={control}
name={`tags.${index}.value`}
render={({ field: { onChange, value } }) => (
<TextInput value={value} onChangeText={onChange} placeholder="Tag" />
)}
/>
<Pressable onPress={() => remove(index)}><Text>✕</Text></Pressable>
</View>
))}
<Pressable onPress={() => append({ value: "" })}><Text>+ Add Tag</Text></Pressable>
</View>
);
}Keyboard Handling
import { KeyboardAvoidingView, Platform, ScrollView } from "react-native";
export function FormScreen() {
return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={{ flex: 1 }}
>
<ScrollView
contentInsetAdjustmentBehavior="automatic"
keyboardShouldPersistTaps="handled" // Tapping outside keyboard doesn't dismiss
>
<LoginForm />
</ScrollView>
</KeyboardAvoidingView>
);
}Testing Forms
import { render, fireEvent, waitFor, screen } from "@testing-library/react-native";
import { userEvent } from "@testing-library/react-native";
it("validates required fields", async () => {
render(<LoginForm onSubmit={jest.fn()} />);
fireEvent.press(screen.getByText("Login")); // Submit without filling
await waitFor(() => {
expect(screen.getByText("Invalid email")).toBeTruthy();
expect(screen.getByText("Min 8 characters")).toBeTruthy();
});
});
it("submits with valid data", async () => {
const onSubmit = jest.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(screen.getByPlaceholderText("Email"), "user@example.com");
await user.type(screen.getByPlaceholderText("Password"), "password123");
await user.press(screen.getByText("Login"));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({ email: "user@example.com", password: "password123" });
});
});Native Capabilities Reference
Camera, location, permissions, haptics, notifications, and biometrics for Expo/React Native.
Permissions
All Expo modules that need permissions expose a use*Permissions() hook. Follow this pattern:
1. Call the permission hook to get current status and a request function 2. Check status — if not granted, show a rationale and call requestPermission() 3. If the user denies twice, canAskAgain becomes false — direct them to Settings
import { useCameraPermissions } from "expo-camera";
const [permission, requestPermission] = useCameraPermissions();
if (!permission?.granted) {
// Show rationale, then call requestPermission()
}| Module | Permission Hook |
|---|---|
expo-camera | useCameraPermissions() |
expo-location | useForegroundPermissions() / useBackgroundPermissions() |
expo-media-library | usePermissions() |
expo-notifications | getPermissionsAsync() / requestPermissionsAsync() |
expo-contacts | usePermissions() |
For modules without a hook, use requestPermissionsAsync() / getPermissionsAsync() directly.
Camera
import { CameraView, useCameraPermissions } from "expo-camera";
const [permission, requestPermission] = useCameraPermissions();
const cameraRef = useRef<CameraView>(null);
// Capture a photo
const photo = await cameraRef.current?.takePictureAsync();
// Toggle front/back
const [facing, setFacing] = useState<"front" | "back">("back");For simple photo/video selection without a camera UI, use expo-image-picker:
import * as ImagePicker from "expo-image-picker";
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ["images"],
allowsEditing: true,
quality: 0.8,
});Location
import * as Location from "expo-location";
// One-time location
const { status } = await Location.requestForegroundPermissionsAsync();
if (status === "granted") {
const location = await Location.getCurrentPositionAsync({});
// location.coords.latitude, location.coords.longitude
}For background location tracking, request requestBackgroundPermissionsAsync() and register a background task. Background location requires the location background mode in app.json:
{
"expo": {
"ios": { "infoPlist": { "UIBackgroundModes": ["location"] } }
}
}Haptics
import * as Haptics from "expo-haptics";
// Light tap feedback (button press)
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
// Success / error / warning
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
// Selection change (picker scroll)
Haptics.selectionAsync();| Style | When to Use |
|---|---|
ImpactFeedbackStyle.Light | Button taps, toggles |
ImpactFeedbackStyle.Medium | Drag snaps, significant actions |
ImpactFeedbackStyle.Heavy | Destructive actions, impacts |
NotificationFeedbackType.Success | Task completed |
NotificationFeedbackType.Warning | Attention needed |
NotificationFeedbackType.Error | Action failed |
selectionAsync() | Picker/slider value changes |
Notifications
Push Notifications (Expo)
import * as Notifications from "expo-notifications";
import * as Device from "expo-device";
async function registerForPushNotifications() {
if (!Device.isDevice) return; // Push doesn't work on simulators
const { status } = await Notifications.requestPermissionsAsync();
if (status !== "granted") return;
const token = await Notifications.getExpoPushTokenAsync({
projectId: "your-project-id", // From app.json > extra > eas > projectId
});
// Send token.data to your server
}Notification Handlers
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
});
// Listen for received/tapped notifications
const subscription = Notifications.addNotificationReceivedListener(notification => {
// Notification received while app is foregrounded
});Biometrics
import * as LocalAuthentication from "expo-local-authentication";
const hasHardware = await LocalAuthentication.hasHardwareAsync();
const isEnrolled = await LocalAuthentication.isEnrolledAsync();
if (hasHardware && isEnrolled) {
const result = await LocalAuthentication.authenticateAsync({
promptMessage: "Authenticate to continue",
fallbackLabel: "Use passcode",
});
if (result.success) {
// Authenticated
}
}Navigation Reference
Expo Router file-based navigation: Stack, Tabs, modals, links, and context menus.
File Conventions
app/
_layout.tsx Root layout (providers, NativeTabs)
index.tsx → /
about.tsx → /about
user/
[id].tsx → /user/:id
[id]/
posts.tsx → /user/:id/posts
(tabs)/
_layout.tsx Tab navigator (group, not in URL)
home.tsx → /home
profile.tsx → /profile
(index,search)/
_layout.tsx Shared Stack for both tabs
index.tsx → /
search.tsx → /search
i/[id].tsx → /i/:id (shared detail screen)
api/
users+api.ts → /api/users (server route)Rules:
- Routes live only in
app/— never co-locate components, types, or utils there - Always have a route matching
/(may be inside a group) - Remove old route files when restructuring navigation
- Use kebab-case filenames
Root Layout (Stack)
// app/_layout.tsx — root is always a Stack
import { Stack } from "expo-router";
export default function RootLayout() {
return (
<Stack
screenOptions={{
headerTransparent: true,
headerLargeTitle: true,
headerBackButtonDisplayMode: "minimal",
}}
>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="user/[id]" options={{ headerLargeTitle: false }} />
</Stack>
);
}Always set page title via `Stack.Screen options.title`, never use a custom Text element as a title.
Tabs — Which to Use
| Scenario | Use |
|---|---|
| Custom design system, cross-platform | JS Tabs (stable, fully customizable) |
| iOS-native look, Liquid Glass (iOS 26+) | NativeTabs (alpha, limited customization) |
JS Tabs
// app/(tabs)/_layout.tsx
import { Tabs } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
export default function TabLayout() {
return (
<Tabs screenOptions={{ tabBarActiveTintColor: "blue" }}>
<Tabs.Screen
name="home"
options={{
tabBarLabel: "Home",
tabBarIcon: ({ color, size }) => <Ionicons name="home" color={color} size={size} />,
}}
/>
</Tabs>
);
}NativeTabs (alpha, iOS 18+)
Alpha API — all tabs render at once, limited customization, max 5 tabs on Android. Use when you want native iOS look (Liquid Glass, native blur/transitions) without rebuilding it yourself.
import { NativeTabs } from "expo-router/unstable-native-tabs";
export default function Layout() {
return (
<NativeTabs>
<NativeTabs.Trigger name="(index)">
<NativeTabs.Trigger.Icon sf="house" />
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="(profile)">
<NativeTabs.Trigger.Icon sf="person" />
<NativeTabs.Trigger.Label>Profile</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
</NativeTabs>
);
}Shared Stack for Multiple Tabs
// app/(index,search)/_layout.tsx — shared Stack for both index and search tabs
import { Stack } from "expo-router/stack";
const tabLabels: Record<string, string> = { index: "Home", search: "Explore" };
export default function Layout({ segment }: { segment: string }) {
const activeTab = segment.replace(/[()]/g, "");
return (
<Stack screenOptions={{ headerLargeTitle: true, headerBackButtonDisplayMode: "minimal" }}>
<Stack.Screen name={activeTab} options={{ title: tabLabels[activeTab] }} />
<Stack.Screen name="i/[id]" options={{ headerLargeTitle: false }} />
</Stack>
);
}Link Component
import { Link } from "expo-router";
// Basic navigation
<Link href="/about">About</Link>
// Dynamic routes
<Link href={`/user/${userId}`}>Profile</Link>
// Wrapping custom component
<Link href="/settings" asChild>
<Pressable><Text>Settings</Text></Pressable>
</Link>Programmatic Navigation
import { useRouter, useLocalSearchParams } from "expo-router";
const router = useRouter();
router.push("/settings");
router.replace("/login"); // No back button
router.back();
// Access route params
const { id } = useLocalSearchParams<{ id: string }>();Modals & Sheets
// Modal presentation
<Stack.Screen options={{ presentation: "modal" }} />
// Form sheet with detents
<Stack.Screen
options={{
presentation: "formSheet",
sheetGrabberVisible: true,
sheetAllowedDetents: [0.5, 1.0],
contentStyle: { backgroundColor: "transparent" }, // Liquid glass on iOS 26+
}}
/>Context Menus on Links
<Link href="/settings" asChild>
<Link.Trigger>
<Pressable><Card /></Pressable>
</Link.Trigger>
<Link.Menu>
<Link.MenuAction
title="Share"
icon="square.and.arrow.up"
onPress={handleShare}
/>
<Link.MenuAction
title="Delete"
icon="trash"
destructive
onPress={handleDelete}
/>
<Link.Menu title="More" icon="ellipsis">
<Link.MenuAction title="Copy" icon="doc.on.doc" onPress={() => {}} />
</Link.Menu>
</Link.Menu>
</Link>Link Previews (iOS only, requires Expo SDK 54+)
<Link href="/detail">
<Link.Trigger>
<Pressable><Card /></Pressable>
</Link.Trigger>
<Link.Preview /> {/* Shows peek preview on 3D touch / long press */}
</Link>Header Search Bar
// In Stack.Screen — preferred over building custom search UI
<Stack.Screen
options={{
headerSearchBarOptions: {
placeholder: "Search...",
onChangeText: (e) => setQuery(e.nativeEvent.text),
onCancelButtonPress: () => setQuery(""),
},
}}
/>Deep Linking
// app.json
{
"expo": {
"scheme": "myapp",
"ios": {
"associatedDomains": ["applinks:myapp.example.com"]
},
"android": {
"intentFilters": [
{
"action": "VIEW",
"autoVerify": true,
"data": [{ "scheme": "https", "host": "myapp.example.com" }],
"category": ["BROWSABLE", "DEFAULT"]
}
]
}
}
}Expo Router handles deep links automatically — /user/123 maps to app/user/[id].tsx.
ScrollView in Routes
When a route belongs to a Stack, its first child should almost always be a ScrollView:
export default function HomeScreen() {
return (
<ScrollView contentInsetAdjustmentBehavior="automatic">
{/* Content */}
</ScrollView>
);
}Use contentInsetAdjustmentBehavior="automatic" on ScrollView, FlatList, and SectionList — this handles safe areas and header insets automatically. Prefer it over <SafeAreaView>.
Networking Reference
Building a robust data layer for Expo apps: API clients, server state, authentication, and server-side routes.
API Client
Setup
Create a thin wrapper around fetch (or expo/fetch on SDK 53+) rather than installing axios. Build a generic request<T>(path, init?) function that:
- Prepends
process.env.EXPO_PUBLIC_API_URLto the path - Defaults
Content-Type: application/json, merges caller headers - On
!res.ok, throws an error withstatusandbodyattached (useObject.assign) so callers can branch on HTTP status - Returns
res.json() as Promise<T>
Then export convenience methods: api.get<T>(path), api.post<T>(path, body), etc., each delegating to request() with the appropriate method and JSON.stringify(body).
Typed Errors
Distinguish network-level failures (no connectivity, DNS) from HTTP-level errors (4xx/5xx). The wrapper above attaches status and body to thrown errors so callers can branch:
try {
await api.post("/tasks", newTask);
} catch (err: any) {
if (err.status === 409) {
Alert.alert("Duplicate", "A task with that title already exists.");
} else if (err.status === undefined) {
Alert.alert("Offline", "Check your connection and try again.");
}
}Server State (React Query)
Provider
// app/_layout.tsx
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
const qc = new QueryClient({
defaultOptions: { queries: { staleTime: 60_000 } },
});
export default function RootLayout() {
return (
<QueryClientProvider client={qc}>
<Stack />
</QueryClientProvider>
);
}Reading Data
function TaskList({ projectId }: { projectId: string }) {
const { data: tasks, isPending, error } = useQuery({
queryKey: ["projects", projectId, "tasks"],
queryFn: () => api.get<Task[]>(`/projects/${projectId}/tasks`),
});
if (isPending) return <ActivityIndicator />;
if (error) return <ErrorBanner message={error.message} />;
return (
<FlashList
data={tasks}
renderItem={({ item }) => <TaskRow task={item} />}
estimatedItemSize={56}
/>
);
}Writing Data
function useCompleteTask(projectId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (taskId: string) => api.put(`/tasks/${taskId}`, { done: true }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["projects", projectId, "tasks"] }),
});
}Optimistic Updates
For snappy UIs, update the cache before the server confirms:
const toggle = useMutation({
mutationFn: (task: Task) => api.put(`/tasks/${task.id}`, { done: !task.done }),
onMutate: async (task) => {
await qc.cancelQueries({ queryKey });
const prev = qc.getQueryData<Task[]>(queryKey);
qc.setQueryData<Task[]>(queryKey, (old) =>
old?.map((t) => (t.id === task.id ? { ...t, done: !t.done } : t)),
);
return { prev };
},
onError: (_err, _task, ctx) => qc.setQueryData(queryKey, ctx?.prev),
onSettled: () => qc.invalidateQueries({ queryKey }),
});Authentication
Storing Credentials
Use expo-secure-store for any token or secret. AsyncStorage is unencrypted and readable on rooted/jailbroken devices.
import * as SecureStore from "expo-secure-store";
const TOKEN_KEY = "session_token";
export async function saveToken(token: string) {
await SecureStore.setItemAsync(TOKEN_KEY, token);
}
export async function getToken() {
return SecureStore.getItemAsync(TOKEN_KEY);
}
export async function clearToken() {
await SecureStore.deleteItemAsync(TOKEN_KEY);
}Injecting Auth Headers
Extend the API client to attach the token automatically:
export async function authRequest<T>(path: string, init?: RequestInit): Promise<T> {
const token = await getToken();
return request<T>(path, {
...init,
headers: { ...init?.headers, ...(token && { Authorization: `Bearer ${token}` }) },
});
}Refreshing Expired Tokens
Avoid stampeding refresh calls when multiple requests discover the token is expired simultaneously. Hold a single in-flight refresh promise and let all waiters share it:
let pending: Promise<string> | null = null;
async function getFreshToken(): Promise<string> {
if (pending) return pending;
pending = (async () => {
const refresh = await SecureStore.getItemAsync("refresh_token");
const { accessToken } = await api.post<{ accessToken: string }>("/auth/refresh", { refresh });
await saveToken(accessToken);
return accessToken;
})();
try {
return await pending;
} finally {
pending = null;
}
}Environment Variables
# .env.development
EXPO_PUBLIC_API_URL=http://localhost:3000
# .env.production
EXPO_PUBLIC_API_URL=https://api.production.example.com- The
EXPO_PUBLIC_prefix makes a variable available in client JS (inlined at build time) - Variables without the prefix are only accessible in server-side API routes
- Never expose database credentials or write-capable API keys via
EXPO_PUBLIC_ - Restart the dev server after editing
.envfiles
Type the variables for autocomplete:
// env.d.ts
declare global {
namespace NodeJS {
interface ProcessEnv {
EXPO_PUBLIC_API_URL: string;
}
}
}
export {};Offline & Connectivity
Track device connectivity with @react-native-community/netinfo and wire it into React Query so queries automatically pause offline and resume on reconnect:
// app/_layout.tsx (once, at startup)
import { onlineManager } from "@tanstack/react-query";
import NetInfo from "@react-native-community/netinfo";
onlineManager.setEventListener((setOnline) =>
NetInfo.addEventListener((state) => setOnline(!!state.isConnected)),
);To show an in-app banner, subscribe separately:
function useOnline() {
const [online, setOnline] = useState(true);
useEffect(() => NetInfo.addEventListener((s) => setOnline(!!s.isConnected)), []);
return online;
}Request Lifecycle
Cancellation
When a component unmounts mid-request, abort the in-flight fetch to avoid setting state on an unmounted component:
useEffect(() => {
const ac = new AbortController();
api.get(`/projects/${id}`, { signal: ac.signal }).then(setProject);
return () => ac.abort();
}, [id]);React Query handles cancellation automatically for queries — no extra work needed.
Retries
React Query retries failed queries by default (3 attempts with exponential backoff). For mutations or non-React-Query code, implement manually:
async function withRetry<T>(fn: () => Promise<T>, attempts = 3): Promise<T> {
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (err) {
if (i === attempts - 1) throw err;
await new Promise((r) => setTimeout(r, 1000 * 2 ** i));
}
}
throw new Error("unreachable");
}Server-Side API Routes
Expo Router supports +api.ts files that run on the server (deployed to EAS Hosting / Cloudflare Workers). Use them when you need to keep secrets server-side, proxy third-party APIs, or run database queries.
Conventions
app/
api/
health+api.ts → GET /api/health
projects+api.ts → GET|POST /api/projects
projects/[id]+api.ts → GET|PUT|DELETE /api/projects/:id
webhooks/payments+api.ts → POST /api/webhooks/paymentsExport a named function per HTTP method:
// app/api/projects+api.ts
export async function GET(req: Request) {
const url = new URL(req.url);
const cursor = url.searchParams.get("cursor");
const rows = await db.query("SELECT * FROM projects WHERE id > ? LIMIT 20", [cursor ?? 0]);
return Response.json(rows);
}
export async function POST(req: Request) {
const { name, description } = await req.json();
const [row] = await db.insert(projectsTable).values({ name, description }).returning();
return Response.json(row, { status: 201 });
}Secrets
Variables without the EXPO_PUBLIC_ prefix are server-only:
// app/api/ai/summarize+api.ts
const LLM_KEY = process.env.LLM_API_KEY; // never reaches the client bundle
export async function POST(req: Request) {
const { text } = await req.json();
const res = await fetch("https://api.llm.example.com/v1/chat", {
method: "POST",
headers: { Authorization: `Bearer ${LLM_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ messages: [{ role: "user", content: `Summarize: ${text}` }] }),
});
return Response.json(await res.json());
}Webhooks
// app/api/webhooks/payments+api.ts — verify signature, then handle event
const event = stripe.webhooks.constructEvent(rawBody, sig, process.env.STRIPE_WH_SECRET!);
if (event.type === "checkout.session.completed") {
await activateSubscription(event.data.object.customer as string);
}Protecting Routes
// lib/require-auth.ts — extract and verify JWT from Authorization header, throw Response on failure
export async function requireAuth(req: Request): Promise<string> {
const header = req.headers.get("Authorization");
if (!header?.startsWith("Bearer "))
throw Response.json({ error: "unauthorized" }, { status: 401 });
const uid = await verifyJwt(header.slice(7));
if (!uid) throw Response.json({ error: "invalid token" }, { status: 401 });
return uid;
}
// Usage in route: const uid = await requireAuth(req);Deploying
npx expo export
eas deploy # preview
eas deploy --prod # production
# Set server-only secrets
eas env:create --name LLM_API_KEY --value "sk-..." --environment productionAPI routes run on Cloudflare Workers — no fs module, 30 s CPU limit, use Web APIs (fetch, crypto.subtle) instead of Node built-ins.
Performance Reference
Diagnosing and fixing performance issues in React Native / Expo apps.
Profiling Workflow
Before optimizing, identify the actual bottleneck:
1. JS thread — Open React Native DevTools (press j in Metro terminal) → Profiler tab → record interaction → look for components with long render times 2. Native thread — iOS: Xcode Instruments (Time Profiler); Android: Android Studio CPU Profiler 3. Measure, don't guess — Always reproduce the issue in a release-like build (npx expo run:ios --configuration Release)
Rendering
Virtualized Lists
Never render large datasets inside a ScrollView. Use a virtualized list that recycles off-screen views:
import { FlashList } from "@shopify/flash-list";
function ProductCatalog({ products }: { products: Product[] }) {
return (
<FlashList
data={products}
renderItem={({ item }) => <ProductRow product={item} />}
estimatedItemSize={72}
keyExtractor={(p) => p.sku}
/>
);
}
const ProductRow = memo(function ProductRow({ product }: { product: Product }) {
return (
<View style={rowStyles.container}>
<Image source={product.thumbnail} style={rowStyles.image} />
<Text style={rowStyles.title}>{product.name}</Text>
</View>
);
});Key points:
- Wrap list items with
memoto skip re-renders when props haven't changed - Always provide
estimatedItemSize— FlashList uses it for layout estimation - Extract
renderItemor use a named component to keep stable references
Minimizing Re-renders
Split state by concern. A single large state object forces every subscriber to re-render on any change:
// Zustand — select only the slice you need
const count = useStore((s) => s.cart.itemCount);
// Jotai — one atom per concern
const cartTotalAtom = atom((get) => {
const items = get(cartItemsAtom);
return items.reduce((sum, i) => sum + i.price * i.qty, 0);
});React Compiler (Expo SDK 54+) automatically memoizes components and hooks. Enable it to eliminate most manual useMemo/useCallback:
// app.json
{ "expo": { "experiments": { "reactCompiler": true } } }Deferred Updates
When a state change triggers expensive computation (filtering a long list, rendering a complex tree), defer the update so typing or scrolling stays responsive:
const [search, setSearch] = useState("");
const deferred = useDeferredValue(search);
const results = useMemo(
() => catalog.filter((p) => p.name.toLowerCase().includes(deferred.toLowerCase())),
[catalog, deferred],
);TextInput on Android
Controlled TextInput (with value + onChangeText) can lag on Android because every keystroke round-trips through the JS thread. For search bars or other high-frequency inputs, prefer uncontrolled mode:
const ref = useRef<TextInput>(null);
<TextInput
ref={ref}
defaultValue=""
onEndEditing={(e) => handleSearch(e.nativeEvent.text)}
/>Startup Time (TTI)
Measuring
import { PerformanceObserver, performance } from "react-native-performance";
performance.mark("nativeLaunch");
export default function App() {
useEffect(() => {
performance.measure("TTI", "nativeLaunch");
}, []);
// ...
}Always measure cold starts — kill the app completely before each measurement.
Reducing TTI
- Android bundle mmap — Set
expo.useLegacyPackaging=falseinandroid/gradle.propertiesso Hermes memory-maps the bundle instead of decompressing it - Preload heavy routes — Call
preloadRouteAsync("/dashboard")(fromexpo-router) while the user is still on the splash/login screen - Lazy-load non-critical screens — Screens behind deep navigation don't need to be in the initial bundle
Bundle & App Size
Inspecting the Bundle
npx expo export --platform ios --source-maps --output-dir dist
npx source-map-explorer dist/bundles/ios/*.jsCommon wins:
- Direct imports —
import groupBy from "lodash/groupBy"instead ofimport { groupBy } from "lodash" - Remove dead Intl polyfills — Hermes ships with built-in
Intlsupport since SDK 50 - Tree shaking — Enable via
"experiments": { "treeShaking": true }in app config (SDK 52+)
Shrinking the Native Binary
# android/gradle.properties
android.enableProguardInReleaseBuilds=trueInspect the final artifact:
- iOS: download the
.ipafrom EAS, unzip, checkPayload/*.appsize - Android: open the
.aab/.apkin Android Studio → Build → Analyze APK
Memory
Preventing Leaks
Every subscription, listener, or long-lived resource acquired in useEffect must be cleaned up:
useEffect(() => {
const sub = AppState.addEventListener("change", onAppStateChange);
return () => sub.remove();
}, []);For fetch calls, pass an AbortSignal and abort on unmount:
useEffect(() => {
const ac = new AbortController();
loadProducts(ac.signal);
return () => ac.abort();
}, [categoryId]);Native Memory
- Monitor with Xcode Memory Graph Debugger (iOS) or Android Studio Memory Profiler
- Release heavy native resources (camera sessions, audio players) in cleanup
- In Swift/Kotlin modules, watch for retain cycles — use
[weak self]/WeakReference
Animations
Only animate `transform` and `opacity`. These properties are composited on the GPU and don't trigger layout recalculation:
const style = useAnimatedStyle(() => ({
opacity: withTiming(visible.value ? 1 : 0),
transform: [{ translateY: withSpring(offset.value) }],
}));Animating width, height, margin, padding, or top/left forces the layout engine to re-measure on every frame — a common source of dropped frames.
Keep gesture callbacks on the UI thread:
const drag = Gesture.Pan().onUpdate((e) => {
"worklet";
translateX.value = e.translationX;
});Native Module Performance
- Prefer async Turbo Module methods — synchronous calls block the JS thread
- Use native SDK implementations over JS polyfills (
expo-cryptoovercrypto-js,react-native-mmkvover AsyncStorage for hot paths) - Android 16KB page alignment is required for Google Play (2025+). Verify third-party
.sofiles are compiled with 16KB alignment
Troubleshooting Guide
| Symptom | Where to Look | Likely Fix |
|---|---|---|
| Scroll jank in long lists | JS thread — re-renders | Virtualized list + memoized items |
| Typing lag in search bar | JS thread — controlled input | Uncontrolled TextInput with defaultValue |
| Slow cold start | Bundle size, sync init | Mmap bundle, preload routes, lazy screens |
| App binary too large | Native assets, unused libs | R8 (Android), analyze bundle, direct imports |
| Growing memory over time | Effect cleanup | Return teardown from every useEffect |
| Choppy enter/exit animation | Animated properties | Only transform + opacity, use worklets |
| Re-renders cascade across app | Global state shape | Atomic selectors (Zustand/Jotai), React Compiler |
State Management Reference
Patterns for local, shared, and server state in React Native / Expo apps.
Decision Guide
| State Type | Solution |
|---|---|
| Local UI state (toggle, input) | useState / useReducer |
| Shared app-wide state | Zustand or Jotai |
| Server/async data | React Query (TanStack Query) |
| Form state | React Hook Form (see forms.md) |
| Auth / session | Zustand + expo-secure-store |
Avoid: Redux for new projects (boilerplate), Context for high-frequency updates (re-render overhead).
useState / useReducer
// Simple toggle
const [isOpen, setIsOpen] = useState(false);
// Complex local state — useReducer
type State = { count: number; status: "idle" | "loading" | "error" };
type Action = { type: "increment" } | { type: "setStatus"; payload: State["status"] };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "increment": return { ...state, count: state.count + 1 };
case "setStatus": return { ...state, status: action.payload };
}
}
const [state, dispatch] = useReducer(reducer, { count: 0, status: "idle" });
dispatch({ type: "increment" });Zustand (Shared State)
npx expo install zustand// stores/settings-store.ts
import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import AsyncStorage from "@react-native-async-storage/async-storage";
interface SettingsStore {
theme: "light" | "dark";
locale: string;
setTheme: (theme: "light" | "dark") => void;
setLocale: (locale: string) => void;
}
export const useSettingsStore = create<SettingsStore>()(
persist(
(set) => ({
theme: "light",
locale: "en",
setTheme: (theme) => set({ theme }),
setLocale: (locale) => set({ locale }),
}),
{
name: "settings-storage",
storage: createJSONStorage(() => AsyncStorage),
}
)
);
// Usage
const { theme, setTheme } = useSettingsStore();
const locale = useSettingsStore((s) => s.locale); // Selector — minimizes re-renders// stores/cart-store.ts
interface CartStore {
items: CartItem[];
add: (product: Product) => void;
remove: (id: string) => void;
clear: () => void;
total: () => number;
}
export const useCartStore = create<CartStore>()((set, get) => ({
items: [],
add: (product) => set((s) => ({
items: [...s.items, { product, quantity: 1 }],
})),
remove: (id) => set((s) => ({
items: s.items.filter((i) => i.product.id !== id),
})),
clear: () => set({ items: [] }),
total: () => get().items.reduce((sum, i) => sum + i.product.price * i.quantity, 0),
}));Jotai (Atomic State)
npx expo install jotai// atoms/user-atoms.ts
import { atom } from "jotai";
import { atomWithStorage, createJSONStorage } from "jotai/utils";
import AsyncStorage from "@react-native-async-storage/async-storage";
const storage = createJSONStorage(() => AsyncStorage);
export const userAtom = atom<User | null>(null);
export const themeAtom = atomWithStorage<"light" | "dark">("theme", "light", storage);
// Derived atom — computed from others
export const isAdminAtom = atom((get) => get(userAtom)?.role === "admin");// Usage — component only re-renders when its atoms change
import { useAtom, useAtomValue, useSetAtom } from "jotai";
function Header() {
const user = useAtomValue(userAtom); // read-only
const setTheme = useSetAtom(themeAtom); // write-only
const [theme, setThemeRW] = useAtom(themeAtom); // read + write
return <Text>{user?.name}</Text>;
}Zustand vs Jotai:
- Zustand — store-based, better for related state with actions (auth, cart)
- Jotai — atom-based, better for independent values, fine-grained subscriptions, avoids re-renders
React Query (Server State)
See networking.md for full reference. Key patterns:
// Queries — read
const { data, isLoading } = useQuery({ queryKey: ["users"], queryFn: fetchUsers });
// Mutations — write
const mutation = useMutation({
mutationFn: createUser,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["users"] }),
});
// Optimistic update
const mutation = useMutation({
mutationFn: updateUser,
onMutate: async (newUser) => {
await queryClient.cancelQueries({ queryKey: ["user", newUser.id] });
const prev = queryClient.getQueryData(["user", newUser.id]);
queryClient.setQueryData(["user", newUser.id], newUser);
return { prev };
},
onError: (_err, variables, context) => {
queryClient.setQueryData(["user", variables.id], context?.prev);
},
});Minimize Re-renders
Zustand Selectors
// ✗ Wrong — re-renders on any store change
const store = useAuthStore();
// ✓ Correct — re-renders only when user changes
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout); // Actions are stable referencesDispatcher Pattern
// ✗ Wrong — passes callbacks that recreate on every render
function Parent() {
const [count, setCount] = useState(0);
return <Child onIncrement={() => setCount(c => c + 1)} />;
}
// ✓ Correct — dispatcher reference is stable
function Parent() {
const [count, dispatch] = useReducer(reducer, 0);
return <Child dispatch={dispatch} />;
}React Compiler (SDK 54+)
With React Compiler enabled, memo, useCallback, and useMemo are often unnecessary:
// app.json
{ "expo": { "experiments": { "reactCompiler": true } } }Context (Use Sparingly)
Context is suitable for infrequently-changing values (theme, locale, auth status). Avoid for high-frequency updates like scroll position or form input.
const ThemeContext = createContext<"light" | "dark">("light");
function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<"light" | "dark">("light");
return <ThemeContext value={theme}>{children}</ThemeContext>; // React 19+
}
// Consume
const theme = use(ThemeContext); // React 19+Fallback on First Render
// ✓ Always show fallback while async state loads
function UserProfile({ userId }: { userId: string }) {
const { data, isLoading } = useQuery({ queryKey: ["user", userId], queryFn: () => fetchUser(userId) });
if (isLoading) return <UserProfileSkeleton />;
if (!data) return null;
return <Profile user={data} />;
}Styling Reference
StyleSheet, NativeWind/Tailwind, platform-specific styles, and theming for Expo/React Native.
StyleSheet
import { StyleSheet } from "react-native";
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: "#fff" },
text: { fontSize: 16, fontWeight: "600" },
});Prefer StyleSheet.create over inline style objects — it validates styles at creation time and enables potential future optimizations.
Platform-Specific Styles
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: 4 },
}),
});Since React Native 0.76+, boxShadow is supported as a unified cross-platform shadow API. Prefer it over platform-specific shadow properties when targeting New Architecture.
NativeWind / Tailwind CSS
For existing projects, check which NativeWind version is in package.json and follow the corresponding docs. For new projects, use NativeWind v4 (stable).
Installation (NativeWind v4)
npx expo install nativewind tailwindcss@3 \
tailwind-merge clsxConfiguration
// babel.config.js
module.exports = function (api) {
api.cache(true);
return {
presets: ["babel-preset-expo"],
plugins: ["nativewind/babel"],
};
};// tailwind.config.js
module.exports = {
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
presets: [require("nativewind/preset")],
theme: { extend: {} },
};/* global.css */
@tailwind base;
@tailwind components;
@tailwind utilities;// app/_layout.tsx
import "../global.css";Usage
<View className="flex-1 bg-white p-4">
<Text className="text-lg font-semibold text-gray-900">Title</Text>
<Pressable className="mt-4 rounded-lg bg-blue-500 px-4 py-2">
<Text className="text-center text-white font-medium">Button</Text>
</Pressable>
</View>Conditional Classes
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
<View className={cn("p-4", isActive && "bg-blue-500", isDisabled && "opacity-50")} />Theming and Dark Mode
For apps using NativeWind, use Tailwind's dark: variant:
<View className="bg-white dark:bg-gray-900">
<Text className="text-gray-900 dark:text-white">Adaptive text</Text>
</View>For StyleSheet-based projects, read the system color scheme and map it to a theme object:
import { useColorScheme } from "react-native";
const colorScheme = useColorScheme(); // "light" | "dark"Keep color tokens in a central constants/colors.ts file with light and dark variants. Pass the active palette via React Context or a Zustand store.
Testing Reference
Jest, React Native Testing Library, and E2E testing for Expo/React Native apps.
Setup
npx expo install jest-expo @testing-library/react-native// package.json
{
"jest": {
"preset": "jest-expo",
"setupFilesAfterSetup": ["@testing-library/react-native/extend-expect"]
}
}npx jest # Run all tests
npx jest --watch # Watch mode
npx jest --coverage # Coverage report
npx jest path/to/test.tsx # Single fileReact Native Testing Library
Basic Component Test
// components/__tests__/Button.test.tsx
import { render, fireEvent, screen } from "@testing-library/react-native";
import { Button } from "../Button";
describe("Button", () => {
it("renders label", () => {
render(<Button label="Submit" onPress={() => {}} />);
expect(screen.getByText("Submit")).toBeTruthy();
});
it("calls onPress when tapped", () => {
const onPress = jest.fn();
render(<Button label="Submit" onPress={onPress} />);
fireEvent.press(screen.getByText("Submit"));
expect(onPress).toHaveBeenCalledTimes(1);
});
it("is disabled when loading", () => {
render(<Button label="Submit" onPress={() => {}} loading />);
expect(screen.getByRole("button")).toBeDisabled();
});
});Queries
// Prefer accessible queries
screen.getByRole("button", { name: "Submit" });
screen.getByLabelText("Email");
screen.getByPlaceholderText("Enter email");
// Text content
screen.getByText("Welcome back");
screen.getByText(/welcome/i); // Regex — case insensitive
// Test IDs (last resort)
screen.getByTestId("user-avatar");
// Async queries
await screen.findByText("Loaded content"); // Waits for element to appear
await screen.findAllByRole("listitem");
// Non-existence
expect(screen.queryByText("Error")).toBeNull();User Events
import { userEvent } from "@testing-library/react-native";
it("submits form on valid input", async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={mockSubmit} />);
await user.type(screen.getByPlaceholderText("Email"), "user@example.com");
await user.type(screen.getByPlaceholderText("Password"), "password123");
await user.press(screen.getByRole("button", { name: "Login" }));
expect(mockSubmit).toHaveBeenCalledWith({
email: "user@example.com",
password: "password123",
});
});Testing Async State
import { waitFor, act } from "@testing-library/react-native";
it("shows user data after loading", async () => {
render(<UserProfile userId="123" />);
// Loading state
expect(screen.getByTestId("loading-indicator")).toBeTruthy();
// Wait for data
await waitFor(() => {
expect(screen.getByText("John Doe")).toBeTruthy();
});
expect(screen.queryByTestId("loading-indicator")).toBeNull();
});Testing with React Query
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
function createTestQueryClient() {
return new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
}
function renderWithQuery(ui: ReactElement) {
const client = createTestQueryClient();
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
}
it("fetches and displays posts", async () => {
// Mock fetch
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve([{ id: "1", title: "Post 1" }]),
});
renderWithQuery(<PostsList />);
await waitFor(() => {
expect(screen.getByText("Post 1")).toBeTruthy();
});
});Testing with Zustand
import { useAuthStore } from "../../stores/auth-store";
beforeEach(() => {
// Reset store state before each test
useAuthStore.setState({ user: null, token: null });
});
it("shows user name when logged in", () => {
useAuthStore.setState({ user: { id: "1", name: "Alice" }, token: "tok" });
render(<Header />);
expect(screen.getByText("Alice")).toBeTruthy();
});Testing Navigation (Expo Router)
import { renderRouter, screen } from "expo-router/testing-library";
it("navigates to detail screen", async () => {
renderRouter({
index: () => <HomeScreen />,
"user/[id]": () => <UserScreen />,
});
fireEvent.press(screen.getByText("View Profile"));
await waitFor(() => {
expect(screen.getByTestId("user-screen")).toBeTruthy();
});
});Mocking
Mock Expo Modules
// __mocks__/expo-secure-store.ts
export const getItemAsync = jest.fn().mockResolvedValue(null);
export const setItemAsync = jest.fn().mockResolvedValue(undefined);
export const deleteItemAsync = jest.fn().mockResolvedValue(undefined);// In test
jest.mock("expo-secure-store", () => ({
getItemAsync: jest.fn().mockResolvedValue("mock-token"),
setItemAsync: jest.fn(),
}));Mock fetch / API calls
beforeEach(() => {
global.fetch = jest.fn();
});
afterEach(() => {
jest.restoreAllMocks();
});
it("handles API error", async () => {
(global.fetch as jest.Mock).mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({ message: "Server error" }),
});
render(<UserProfile userId="123" />);
await waitFor(() => {
expect(screen.getByText("Server error")).toBeTruthy();
});
});Mock react-native modules
// jest.setup.ts
jest.mock("react-native/Libraries/Animated/NativeAnimatedHelper");
jest.mock("@react-native-community/netinfo", () => ({
addEventListener: jest.fn(() => jest.fn()),
fetch: jest.fn(() => Promise.resolve({ isConnected: true })),
}));Unit Tests (Non-UI Logic)
// utils/__tests__/format.test.ts
import { formatCurrency, formatDate } from "../format";
describe("formatCurrency", () => {
it("formats USD", () => expect(formatCurrency(1234.5, "USD")).toBe("$1,234.50"));
it("handles zero", () => expect(formatCurrency(0, "USD")).toBe("$0.00"));
it("handles negative", () => expect(formatCurrency(-50, "USD")).toBe("-$50.00"));
});// stores/__tests__/cart-store.test.ts
import { useCartStore } from "../cart-store";
beforeEach(() => useCartStore.setState({ items: [] }));
describe("CartStore", () => {
it("adds item", () => {
useCartStore.getState().add(mockProduct);
expect(useCartStore.getState().items).toHaveLength(1);
});
it("calculates total", () => {
useCartStore.getState().add({ ...mockProduct, price: 10 });
expect(useCartStore.getState().total()).toBe(10);
});
});E2E Testing (Maestro)
Maestro is the recommended E2E tool for Expo — no build configuration needed.
# Install
curl -Ls "https://get.maestro.mobile.dev" | bash
# Run flow
maestro test flows/login.yaml# flows/login.yaml
appId: com.example.myapp
---
- launchApp
- tapOn:
text: "Sign In"
- inputText:
id: "email-input"
text: "user@example.com"
- inputText:
id: "password-input"
text: "password123"
- tapOn:
text: "Login"
- assertVisible:
text: "Welcome back"
- takeScreenshot: login-success# flows/create-post.yaml
appId: com.example.myapp
---
- launchApp
- runFlow: ./login.yaml
- tapOn:
id: "new-post-button"
- inputText:
id: "post-title"
text: "My Test Post"
- tapOn:
text: "Publish"
- assertVisible:
text: "My Test Post"Testing Checklist
| Layer | What to Test |
|---|---|
| Unit | Business logic, stores, utility functions, hooks |
| Component | Renders correctly, user interactions, loading/error states |
| Integration | Component + store/query working together |
| E2E | Critical user flows (login, checkout, core feature) |
Common Mistakes
| Wrong | Right |
|---|---|
getByTestId everywhere | Use accessible queries (getByRole, getByLabelText) |
| Testing implementation details | Test behavior the user sees |
No waitFor on async operations | waitFor or findBy* for async |
| Real network calls in tests | Mock fetch or use MSW |
| Testing every line | Focus on behavior, not coverage % |
Related skills
FAQ
Which list component does this skill recommend for React Native?
FlashList from @shopify/flash-list with memoized items, instead of FlatList which has no view recycling.
How does it recommend starting a new project?
npx create-expo-app@latest with the blank-typescript template, then install Expo Router and core deps.