Skip to content
My Guidelines
Guides
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. useState lifted into Context is NOT a state management strategy. Never place framework code in domain/ or application/ — see mobile-architecture.md.

Project-agnostic: samples use a placeholder Expense domain. 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.

MilestoneVersion
New Architecture became the defaultRN 0.76 / Expo SDK 52
Legacy Architecture deleted from the codebaseRN 0.83 / Expo SDK 55
Current baseline, verified 2026-08-25Expo 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 configConfig plugins, declarativeManual, per platform
OTA updatesEAS Update, built inRoll your own
Upgradesexpo install --fixManual 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:

app.config.ts
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

ConcernUseNever
NavigationExpo RouterHand-rolled navigator trees
Server stateTanStack QueryRedux for API responses
Client stateZustand (or Jotai for atomic)useState + Context as global store
Key–value storageMMKV (v4: createMMKV(), remove() — new MMKV() and .delete() are v3)AsyncStorage (slow, async, unencrypted)
Secretsexpo-secure-storeMMKV / AsyncStorage
Relational storageexpo-sqlite, or Drizzle/WatermelonDBJSON blobs you query by hand
ListsFlashListFlatList beyond a few hundred rows
AnimationReanimated + Gesture HandlerAnimated from RN core
Imagesexpo-imageRN core Image
FormsReact Hook Form + Zod resolverManual useState per field
ValidationZodHand-written type guards
Bottom sheets / modals@gorhom/bottom-sheetReimplementing gestures
Crash reportingSentryNothing

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 stateClient state
OwnerThe backendThe device
ExamplesExpense list, user profileSelected filter, theme, wizard step
ToolTanStack QueryZustand / Jotai
TraitsAsync, stale, shared, cachedSync, 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 state
export function useExpenses(period: Period) {
const { listExpenses } = useContainer()
return useQuery({
queryKey: ['expenses', period],
queryFn: () => listExpenses(period),
staleTime: 30_000,
})
}
// ui/store/filterStore.ts — client state
export 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 changes
const 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.tsx

Enable 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/.

app/expense/[id].tsx
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.

infrastructure/http/client.ts
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. fetch has no default; a hung request spins forever.
  • Distinguish Timeout, Network, Unauthorized, Server — the UI reacts differently to each.
  • Never console.log the 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 FlatList past a few hundred rows.
  • Give it an accurate estimatedItemSize. A wrong estimate is worse than none.
  • renderItem must 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 / useMemo only 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 .env files. Commit .env.example.
  • Variants (dev / staging / production) differ by bundle identifier, app name and icon so they coexist on one device.

9. TypeScript configuration

tsconfig.json
{
"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

ToolRole
ESLint (eslint-config-expo) + import/no-restricted-pathsCorrectness + architectural boundaries
Prettier or BiomeFormatting only — pick one, never both
TypeScript --noEmitType check in CI
npx expo installInstalls versions matching the SDK. See the warning below.
KnipDead 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 npmExpo expectsSymptom
jest@30jest@~29.7.0this._moduleMocker.clearMocksOnScope is not a function — every suite fails to run
eslint@10eslint@^9contextOrFilename.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 projectDevelopment build
API response stored in ZustandTanStack Query
const s = useStore()useStore((s) => s.field)
fetch inside a componentAdapter behind a port
fetch with no timeoutAbortController with an explicit timeout
FlatList with thousands of rowsFlashList with a real estimatedItemSize
keyExtractor={(_, i) => String(i)}Stable domain id
Secret in EXPO_PUBLIC_*Server-side, or EAS Secret
React.memo on everythingProfile first
Business logic in app/ route filesRoute wires params; screen renders; use case decides
Performance judged on an iOS simulatorLow-end physical Android

12. Pre-merge checklist

  • No fetch or 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_* or app.config.ts
  • tsc --noEmit, ESLint and boundary rules pass
  • npx expo install --check reports no outdated dependencies
  • Verified on a low-end physical Android device