In this guide (19 items)
React Native & Expo
React Native
MANDATORY FOR ALL AI AGENTS: When generating React Native code you MUST follow every convention below. Expo is the default — do NOT generate bare React Native projects unless an ADR justifies it. Server state MUST go through TanStack Query; client state through Zustand or Jotai.
useStatelifted into Context is NOT a state management strategy. Never place framework code indomain/orapplication/— see mobile-architecture.md.
Project-agnostic: samples use a placeholder
Expensedomain. Replace with your actual domain.
Companion command: /mobile-init-project — creates the Expo project, app.config.ts, eas.json and the baseline dependency set below.
This is the mobile counterpart of spring-boot.md: the framework integration guide. Architecture rules live in mobile-architecture.md; code conventions in typescript-code-style.md.
1. Runtime baseline
The New Architecture (Fabric renderer, TurboModules, JSI, Codegen) is no longer a choice.
| Milestone | Version |
|---|---|
| New Architecture became the default | RN 0.76 / Expo SDK 52 |
| Legacy Architecture deleted from the codebase | RN 0.83 / Expo SDK 55 |
| Current baseline, verified 2026-08-25 | Expo SDK 57, RN 0.86.2, React 19.2.3, TypeScript 6.0.3 |
There is no migration to plan on a new project — you start on the New Architecture. The one thing that still matters: verify every third-party native library supports it before adopting it. The ecosystem has caught up, but abandoned packages have not.
Version numbers age. Verify the current SDK against the Expo release notes before pinning; the rules in this guide do not change with the version.
⚠️ The project must not live inside iCloud Drive, Dropbox or any file-sync client — iOS codesigning fails there and xattr -cr cannot fix it. See mobile-release.md §2.
⚠️ newArchEnabled no longer exists in ExpoConfig as of SDK 57 — the New Architecture is unconditional. Setting it is a type error.
2. Expo is the default
| Expo (default) | Bare React Native | |
|---|---|---|
| iOS build without a Mac | ✅ EAS Build | ❌ Requires macOS + Xcode |
| Native module config | Config plugins, declarative | Manual, per platform |
| OTA updates | EAS Update, built in | Roll your own |
| Upgrades | expo install --fix | Manual diff of native folders |
Use Expo with development builds — not Expo Go. Expo Go cannot load custom native modules, so every project outgrows it within weeks. Start with a dev build and skip the migration.
Choosing bare RN requires an ADR stating which specific capability Expo cannot provide. “More control” is not a reason.
app.config.ts, not app.json
Use the TypeScript form so environment values are typed and computed:
import type { ExpoConfig } from 'expo/config'
export default ({ config }): ExpoConfig => ({ ...config, name: process.env.APP_VARIANT === 'production' ? 'Expense' : 'Expense (dev)', slug: 'expense', scheme: 'expense', ios: { bundleIdentifier: bundleIdFor(process.env.APP_VARIANT) }, android: { package: bundleIdFor(process.env.APP_VARIANT) }, plugins: ['expo-router', 'expo-secure-store'],})Different bundle identifiers per variant let dev and production coexist on one device. Do this from day one; retrofitting it invalidates installs.
⚠️ Everything in app.config.ts ships inside the binary and is readable. No secrets. See mobile-security.md.
3. Library selection
| Concern | Use | Never |
|---|---|---|
| Navigation | Expo Router | Hand-rolled navigator trees |
| Server state | TanStack Query | Redux for API responses |
| Client state | Zustand (or Jotai for atomic) | useState + Context as global store |
| Key–value storage | MMKV (v4: createMMKV(), remove() — new MMKV() and .delete() are v3) | AsyncStorage (slow, async, unencrypted) |
| Secrets | expo-secure-store | MMKV / AsyncStorage |
| Relational storage | expo-sqlite, or Drizzle/WatermelonDB | JSON blobs you query by hand |
| Lists | FlashList | FlatList beyond a few hundred rows |
| Animation | Reanimated + Gesture Handler | Animated from RN core |
| Images | expo-image | RN core Image |
| Forms | React Hook Form + Zod resolver | Manual useState per field |
| Validation | Zod | Hand-written type guards |
| Bottom sheets / modals | @gorhom/bottom-sheet | Reimplementing gestures |
| Crash reporting | Sentry | Nothing |
Every addition to this list is an ADR. Two libraries doing the same job is a defect.
4. State — the rule that matters most
Server state and client state are different problems. Do not store them in the same place.
This is the single most common architectural mistake in React Native apps.
| Server state | Client state | |
|---|---|---|
| Owner | The backend | The device |
| Examples | Expense list, user profile | Selected filter, theme, wizard step |
| Tool | TanStack Query | Zustand / Jotai |
| Traits | Async, stale, shared, cached | Sync, always fresh, local |
Putting an API response into Zustand means reimplementing caching, invalidation, retries, deduplication and background refetch by hand — badly.
// ui/hook/useExpenses.ts — server stateexport function useExpenses(period: Period) { const { listExpenses } = useContainer() return useQuery({ queryKey: ['expenses', period], queryFn: () => listExpenses(period), staleTime: 30_000, })}// ui/store/filterStore.ts — client stateexport const useFilterStore = create<FilterState>()((set) => ({ category: 'all', setCategory: (category) => set({ category }),}))Select narrowly from Zustand. const store = useFilterStore() re-renders on every field change:
// ✅ re-renders only when category changesconst category = useFilterStore((s) => s.category)Context is for dependency injection, not for state. The container provider from mobile-architecture.md §6 is a correct use: its value never changes, so it never re-renders the tree.
5. Navigation — Expo Router
File-based routing. Deep links, typed routes and universal links come for free.
app/├── _layout.tsx root: providers, container, theme├── (tabs)/│ ├── _layout.tsx│ ├── index.tsx → /│ └── settings.tsx → /settings├── expense/│ └── [id].tsx → /expense/:id└── +not-found.tsxEnable typed routes in app.config.ts (experiments.typedRoutes) so a renamed screen breaks the build instead of producing a dead link at runtime.
Route files stay thin. They wire params and render a screen from ui/feature/. Business logic never lives in app/.
export default function ExpenseDetailRoute() { const { id } = useLocalSearchParams<{ id: string }>() const parsed = expenseIdSchema.safeParse(id) if (!parsed.success) return <NotFound /> return <ExpenseDetailScreen id={parsed.data} />}⚠️ Route params are attacker-controlled. Any app on the device can open yourapp://expense/<anything>. Parse them (§boundary rules in mobile-architecture.md §7) and never trust them for authorization.
6. Data fetching
One HTTP client, in infrastructure/http/. Never call fetch from a component.
export function httpClient(config: HttpConfig): HttpClient { return { async get(path, init) { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), config.timeoutMs) try { const res = await fetch(`${config.baseUrl}${path}`, { ...init, signal: controller.signal, headers: { ...init?.headers, ...(await config.authHeader()) }, }) if (!res.ok) return err(toHttpError(res.status)) return ok(await res.json()) } catch (e) { return err(isAbort(e) ? { kind: 'Timeout' } : { kind: 'Network' }) } finally { clearTimeout(timeout) } }, }}Non-negotiable:
- Always set a timeout.
fetchhas no default; a hung request spins forever. - Distinguish
Timeout,Network,Unauthorized,Server— the UI reacts differently to each. - Never
console.logthe response body in release. It leaks tokens and PII. - Retries and backoff belong to TanStack Query or a decorator, not to the client.
7. Performance
Measure before optimizing. React.memo everywhere is worse than nothing — it adds comparison cost to components that were never the problem.
Lists
- FlashList over
FlatListpast a few hundred rows. - Give it an accurate
estimatedItemSize. A wrong estimate is worse than none. renderItemmust be cheap and its component memoized. No.filter(), no date formatting, no object literals created inline per row.- Stable
keyExtractor. Never the array index.
Re-renders
- Narrow Zustand selectors (§4).
useCallback/useMemoonly when the value crosses a memo boundary or a dependency array. Otherwise it is noise.- Never create objects or arrays inline in props of a memoized child.
Animation
Reanimated worklets run on the UI thread and keep animating while JS is busy. Animated from RN core does not. Use Reanimated for anything gesture-driven or continuous.
Startup
Cold start is the metric users feel.
- Lazy-load heavy screens.
- Do not block the splash screen on network calls.
- Keep the root layout thin — providers only.
- Trim the dependency tree; every native module costs startup time.
Images
expo-image with explicit dimensions and appropriately sized sources. Full-resolution photos in a list is the most common memory-pressure bug in RN apps.
Profiling
React DevTools Profiler, Flipper/Hermes tooling, and Expo’s performance monitor. Measure on a low-end physical Android device — never judge performance from an iPhone simulator on an M-series Mac.
8. Environment configuration
- Public, non-secret values:
EXPO_PUBLIC_*env vars. They are inlined into the bundle and visible to anyone who unzips the app. - Secrets: server-side only, or EAS Secrets for build-time credentials that never reach the bundle.
- Never commit
.envfiles. Commit.env.example. - Variants (
dev/staging/production) differ by bundle identifier, app name and icon so they coexist on one device.
9. TypeScript configuration
{ "extends": "expo/tsconfig.base", "compilerOptions": { "strict": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "noImplicitOverride": true, "paths": { "@domain/*": ["./src/domain/*"], "@application/*": ["./src/application/*"], "@infrastructure/*": ["./src/infrastructure/*"], "@ui/*": ["./src/ui/*"] } }}noUncheckedIndexedAccess is the one people disable and then regret: without it array[0] is typed as defined even when the array is empty. See typescript-code-style.md.
10. Tooling
| Tool | Role |
|---|---|
ESLint (eslint-config-expo) + import/no-restricted-paths | Correctness + architectural boundaries |
| Prettier or Biome | Formatting only — pick one, never both |
TypeScript --noEmit | Type check in CI |
npx expo install | Installs versions matching the SDK. See the warning below. |
| Knip | Dead code and unused dependency detection |
This mirrors the Spotless/Detekt split in gradle.md: formatting and static analysis are separate jobs. All three run in CI and all three must fail the build.
⚠️ Install dev tooling with npx expo install, not npm install. Expo pins the tooling versions its preset supports; npm installs latest, which silently breaks. Two real failures observed on SDK 57:
| Installed by npm | Expo expects | Symptom |
|---|---|---|
jest@30 | jest@~29.7.0 | this._moduleMocker.clearMocksOnScope is not a function — every suite fails to run |
eslint@10 | eslint@^9 | contextOrFilename.getFilename is not a function in eslint-plugin-react — lint cannot run at all |
Run npx expo install --check before trusting a green build.
11. Anti-patterns
| ❌ | ✅ |
|---|---|
| Expo Go for a real project | Development build |
| API response stored in Zustand | TanStack Query |
const s = useStore() | useStore((s) => s.field) |
fetch inside a component | Adapter behind a port |
fetch with no timeout | AbortController with an explicit timeout |
FlatList with thousands of rows | FlashList with a real estimatedItemSize |
keyExtractor={(_, i) => String(i)} | Stable domain id |
Secret in EXPO_PUBLIC_* | Server-side, or EAS Secret |
React.memo on everything | Profile first |
Business logic in app/ route files | Route wires params; screen renders; use case decides |
| Performance judged on an iOS simulator | Low-end physical Android |
12. Pre-merge checklist
- No
fetchor storage access inside a component - Server state in TanStack Query, client state in Zustand, no crossover
- Zustand selectors are narrow
- Route params parsed before use
- Every network call has a timeout and typed errors
- Lists use FlashList with an accurate
estimatedItemSize - No secrets in
EXPO_PUBLIC_*orapp.config.ts -
tsc --noEmit, ESLint and boundary rules pass -
npx expo install --checkreports no outdated dependencies - Verified on a low-end physical Android device