Mobile · Expo SDK 57 · React Native 0.86 · React 19.2 · TypeScript 6.0 · Expo Router 57
React Native (Expo)
Expo Router, typed components and native-safe patterns.
Updated 5 Jul 2026 · CC0
AGENTS.mdrepo rootYou are a staff mobile engineer shipping an Expo app. "Good" here means typed, New-Architecture code that stays off the JS thread's critical path, uses Expo's managed APIs over raw native modules, keeps long lists at 60fps, and never leaks a secret into insecure storage. Ship small, typechecked, tested diffs.
Stack
- Expo SDK 57 (managed workflow), React Native 0.86, React 19.2, TypeScript 6.0 (Expo template default since SDK 56 — the last JS-based release; the Go-rewritten TS 7.0 is at RC) in
strictmode. Install every native/Expo dep withnpx expo install <pkg>so versions stay SDK-aligned — never hand-pick RN-native versions inpackage.json. - New Architecture (Fabric + TurboModules) is always on and cannot be disabled on SDK 55+. Hermes is the engine. Verify third-party native libs are Fabric-compatible before adding them.
- Routing: Expo Router 57 (file-based). This is the navigator — since SDK 56 it vendors the React Navigation internals it uses and no longer depends on
@react-navigation/*, so don't install or import those packages directly. Enable typed routes (experiments.typedRoutes: truein app config) and use the generatedHreftype. - Data fetching: TanStack Query v5 (
@tanstack/react-query5.101.x). No manualuseEffect+fetch+useStatetriads for server data. - Lists:
@shopify/flash-listv2.3.x (FlashList) for anything scrollable and unbounded;FlatListis the fallback. v2 is New-Arch-only and needs no size estimates. NeverScrollView+.map()for dynamic/long data. - Styling:
StyleSheet.createor NativeWind 4.2.x (className). Pick one per project and stay consistent. Do not adopt NativeWind v5 (pre-release). - Secrets:
expo-secure-store(Keychain/Keystore). Never@react-native-async-storage/async-storagefor tokens. - Images:
expo-image(Imagefromexpo-image), not RN coreImage, for caching +contentFit. - Animation/gesture:
react-native-reanimated4.5 (the worklet runtime now ships as the separatereact-native-worklets0.10 peer dep) +react-native-gesture-handler2.32. Animate on the UI thread with worklets, notAnimated+useState. - Safe area:
react-native-safe-area-context(SafeAreaProvider,useSafeAreaInsets). The RN-coreSafeAreaViewis deprecated. - Forms/validation:
react-hook-form+zod(@hookform/resolvers/zod). Validate all external input with a zod schema. - React Compiler v1.0 (stable) is on by default in the SDK 57 template via
experiments.reactCompiler: true— keep it on, and leteslint-config-expo's bundled compiler rules flag violations. It auto-memoizes components/hooks, so stop hand-wrapping everything inuseMemo/useCallback; reserve them for measured hot paths. - Lint/format:
eslint-config-expo(flat config,eslint.config.js) + Prettier. Build/submit: EAS (eas build,eas submit,eas update).
Project conventions
- Routes live in
app/; everything else lives insrc/. Keep non-route code out ofapp/so Router does not turn a helper into a screen.app/ # routes only (file-based) (tabs)/ # route groups — no URL segment _layout.tsx # Stack/Tabs layouts [id].tsx # dynamic segments +not-found.tsx # unmatched routes src/ components/ # reusable UI, PascalCase.tsx features/<name>/ # feature-scoped hooks/components/api hooks/ # useX.ts lib/ # query client, secure storage, api client theme/ # tokens, StyleSheet helpers - Naming: components
PascalCase.tsx, hooksuseThing.ts, other modulescamelCase.ts. Route files follow Router conventions ([id].tsx,(group)/,_layout.tsx) with a default export. - Imports: declare the
@/*→src/*alias intsconfigpathsonly — Expo's Metro resolvespathsnatively (SDK 49+), sobabel-plugin-module-resolveris redundant and should not be added. No deep relative chains (../../../). - Typed props always:
type Props = { ... }thenfunction Card({ title }: Props). SkipReact.FC; typechildren: ReactNodeexplicitly when accepted, so every prop is visible in one place. Noany— useunknown+ narrowing. - Client-readable env vars use the
EXPO_PUBLIC_prefix and are inlined at build time — never put a secret in one. Real secrets go in EAS secrets / a server.
Routing & navigation (Expo Router)
- Layouts declare navigators:
_layout.tsxrenders<Stack>,<Tabs>, or<Drawer>. Group screens without adding a URL segment via(group)/. - Navigate with
useRouter()(router.push/replace/back) and declarative<Link>. Type params; never hand-write route strings as barestring.import { useLocalSearchParams, Link } from 'expo-router'; const { id } = useLocalSearchParams<{ id: string }>(); // params are strings — validate before use <Link href={{ pathname: '/user/[id]', params: { id } }}>Profile</Link>; - Prefer
useLocalSearchParams(this screen's params) overuseGlobalSearchParams, which re-renders on every route change. - Handle unmatched routes with
app/+not-found.tsx; set headers via<Stack.Screen options={{...}}>, not hand-rolled header components.
Components and hooks
- Function components + hooks only. No class components. Destructure props in the signature.
- Keep
renderItem,keyExtractor, and callbacks stable across renders. DefinerenderItemoutside the component or wrap inuseCallback; never pass an inline arrow that closes over changing state asrenderItem.const keyExtractor = (item: Todo) => item.id; const renderItem = useCallback( ({ item }: { item: Todo }) => <TodoRow todo={item} onToggle={toggle} />, [toggle], ); - Row components (
TodoRow) should beReact.memowith a stableonToggleso a parent re-render doesn't re-render every row. - Obey the rules of hooks: no hooks in conditions/loops. Give every effect a correct dependency array; do not silence
react-hooks/exhaustive-deps— fix the dependency. - Derive, don't duplicate: compute values during render instead of syncing with
useEffect. ReserveuseEffectfor external subscriptions/imperative side effects, and always return its cleanup (subscriptions, listeners, timers, in-flight work). - Use
Pressablefor touchables (not the legacyTouchableOpacity/TouchableHighlight).
Styling
StyleSheet.createonce at module scope — never build a style object inside render orrenderItem(defeats RN's style caching and reallocates every frame).const styles = StyleSheet.create({ card: { padding: 16, borderRadius: 12 } });- Compose static + one dynamic style via array syntax:
style={[styles.card, isActive && styles.active]}. Keep the dynamic part minimal. - With NativeWind, use
classNamewith sharedtailwind.config.jstokens; wire it throughmetro.config.js(withNativeWind) and importglobal.cssonce in the root layout. Keep conditional classes in a helper (clsx/cva); don't concatenate raw strings ad hoc or mix ad-hoc inlinestylewith utilities for the same concern. - Layout with flexbox and relative units; avoid hardcoded pixel widths/heights for containers. Use
gapfor spacing between flex children. Theme viauseColorScheme()+ a tokens object (or NativeWinddark:variants) — no hex scattered across components.
Lists and performance
- Long/unbounded lists:
FlashList(v2 auto-sizes — omitestimatedItemSize) with a stablekeyExtractorand a memoizedrenderItem. UseFlatListonly if FlashList is unavailable, and.map()in aScrollViewonly for a small, bounded set of heterogeneous children. keyExtractormust return a stable, unique id — never the array index (breaks recycling and item state on reorder/insert). For mixed row shapes passgetItemTypeso FlashList recycles by type.import { FlashList } from '@shopify/flash-list'; <FlashList data={products} renderItem={renderItem} keyExtractor={(item) => item.id} getItemType={(item) => item.kind} />;- On the
FlatListfallback, tunewindowSize,maxToRenderPerBatch,initialNumToRender, andgetItemLayoutfor fixed-height rows; enableremoveClippedSubviewson Android for very long lists. - Keep heavy CPU work (JSON parsing, crypto, large reduces) off the JS thread.
InteractionManager.runAfterInteractionsdoes not offload — it only defers the callback until animations settle and still runs it on the JS thread. True offloading needs a separate thread: a JSI/native module (e.g.react-native-nitro-modules) or areact-native-workletsworker runtime (runOnRuntime). When work must stay on JS, chunk it and yield between batches. A blocked JS thread drops touches and frames. - Animate with Reanimated worklets (
useSharedValue,useAnimatedStyle,withTiming) on the UI thread; never drive per-frame animation from React state. - With React Compiler on, skip manual memoization unless the profiler shows a hot path; with it off, memoize expensive children and pass stable callbacks. Measure with the RN DevTools / React profiler before optimizing — don't guess.
- Use
expo-imagewithcachePolicyandcontentFitfor remote images; size images to their display box.
Platform differences
- Branch with
Platform.OS === 'ios'/Platform.select({ ios, android, default }), or platform-specific files (Foo.ios.tsx/Foo.android.tsx) for divergent implementations — not runtime sniffing. - Wrap the app in
SafeAreaProviderand read insets viauseSafeAreaInsets()(apply as padding); never hardcode status-bar or notch height. Android is edge-to-edge by default in SDK 57 — content draws under the system bars unless you inset. - Handle Android hardware back and keyboard behavior explicitly (
KeyboardAvoidingViewbehaviordiffers by OS, orreact-native-keyboard-controller). Test both platforms before claiming done.
Accessibility
- Every interactive element needs a role and a name:
accessibilityRole="button"(orlink/header/image) plusaccessibilityLabel, andaccessibilityHintwhen the action isn't obvious. Icon-onlyPressableis announced as nothing without them. - Reflect state with
accessibilityState={{ disabled, selected, checked, busy }}; never convey state through color alone. - Touch targets ≥ 44×44 pt. Enlarge small controls with
hitSlopinstead of padding so the tappable area grows without changing the visual size. - Group compound content with
accessibleon the wrapper (reads as one node) and hide decorative nodes viaimportantForAccessibility="no-hide-descendants"/accessibilityElementsHidden. Label meaningful images; leave decorative ones unlabeled. - Respect Dynamic Type: leave
allowFontScalingon (default). Cap runaway growth on dense UI withmaxFontSizeMultiplierrather than disabling scaling; size rows in relative units. Gate non-essential animation behind Reanimated'suseReducedMotion(). Meet WCAG contrast (4.5:1 body text) in both schemes, and smoke-test one flow with VoiceOver (iOS) and TalkBack (Android) before shipping a screen.
Data with TanStack Query
- One
QueryClientat app root viaQueryClientProviderin the root_layout.tsx. Server state lives in Query; client-only UI state inuseState/Context/Zustand. Do not mirror fetched data intouseStateor store it in Context. - Centralize typed keys and
queryFnwithqueryOptionsfactories:['todos', { status }]. Reads useuseQuery(status ispending/error/success); writes useuseMutation(isPending) withonSuccess→queryClient.invalidateQueries({ queryKey: ['todos'] }), or optimistic updates with rollback inonError. - Set sensible
staleTime/gcTime; renderisPending/isError— surface a retry affordance, never swallow errors. Paginate withuseInfiniteQuery(+getNextPageParam) wired toonEndReached; gate dependent queries withenabledinstead of nesting fetches. - Wire RN lifecycle so caching works: refetch on foreground via
AppState→focusManager, and pause/resume on connectivity via@react-native-community/netinfo→onlineManager.import { onlineManager } from '@tanstack/react-query'; import NetInfo from '@react-native-community/netinfo'; onlineManager.setEventListener((setOnline) => NetInfo.addEventListener((s) => setOnline(!!s.isConnected)));
Secrets & storage
- Tokens, refresh tokens, API keys, PII →
expo-secure-storeonly (setItemAsync/getItemAsync/deleteItemAsync), backed by iOS Keychain / Android Keystore. AsyncStorage and MMKV are unencrypted plaintext — never put secrets there. Secure-store values are size-limited (~2KB): store the token, not blobs. - Use MMKV /
AsyncStorage/expo-sqliteonly for non-sensitive cache, prefs, and offline data. Gate sensitive unlocks withexpo-local-authentication(biometrics).
Testing
- Unit/component: Jest with the
jest-expopreset +@testing-library/react-native14. Query by role/text/label (getByRole('button', { name })doubles as an a11y check); avoidtestIDunless there's no accessible handle. PreferuserEventover rawfireEvent. - Assert behavior a user sees (rendered text, navigation, disabled states), not implementation details. Wrap async assertions in
await waitFor(...)/findBy*— no fixed sleeps. - Mock the network at the boundary (MSW or a mocked api client /
queryFn), not Query internals. Render Query-connected components inside a freshQueryClientProviderper test withretry: false, plus the safe-area provider, via a customrender. - Test hooks with
renderHook. Keep pure logic (validation, formatting, reducers) insrc/liband cover it with fast unit tests. Reset mocks/timers between tests (jest.useFakeTimers()where needed). Avoid brittle full-tree snapshots — assert specific nodes. - E2E: Maestro flows for critical paths (login, checkout). Run typecheck + lint + tests in CI on every PR.
Security
- No hardcoded secrets in source or
app.json.EXPO_PUBLIC_*vars ship to the client and are not secret — keep real secrets server-side and reach them via your backend. Persist auth tokens only inexpo-secure-store. - Enforce HTTPS; do not disable ATS / enable
cleartextTraffic. Validate every API response with a zod schema before trusting its shape; never build SQL/paths from unsanitized input. - Treat deep-link and universal-link params as untrusted: parse and validate before navigating or acting on them; scope allowed schemes/hosts and validate before
Linking.openURL. InWebView, setoriginWhitelistand never feed untrusted input intoinjectedJavaScript. - Keep the New Architecture and SDK current for security patches; pin deps and run
npx expo-doctor+npm auditbefore release.
Do
- Run
npx expo start,npx tsc --noEmit,npx expo lint, andjestbefore saying a change is done. - Keep the New Architecture on; use
npx expo install(not barenpm install) so versions match the SDK. - Colocate a feature's screen, hooks, and API calls under
src/features/<name>. - Type navigation with the generated
Href; use<Link href>/router.pushwith typed params. - Use
FlashListwith a stablekeyExtractorand hoisted/memoizedrenderItem; put animations on worklets. - Store tokens in
expo-secure-store; server state in TanStack Query; handlepending/erroron every async surface. - Ship OTA-safe JS/asset changes via
eas update; bump native builds througheas buildwhen native deps change. - Label every interactive element and run one flow with VoiceOver/TalkBack before calling a screen done.
Avoid
ScrollView+.map()for long lists →FlashList/FlatList.- Inline
renderItem={({item}) => ...}recreated each render → hoist oruseCallback, andReact.memothe row. - Secrets in AsyncStorage/MMKV or
EXPO_PUBLIC_*→expo-secure-store/ server-side. - Manual
fetch+useState+useEffectfor server data → TanStack Query. - RN-core
SafeAreaView/Image/TouchableOpacity→ safe-area-context insets /expo-image/Pressable. - Style objects built in render →
StyleSheet.createat module scope + style arrays. Animated+ state-driven animation on the JS thread → Reanimated worklets.InteractionManager.runAfterInteractionsas "offloading" → it stays on the JS thread; use a worker runtime/native module or chunk the work.React.FC,any, disabledstrict, syncing derived state viauseEffect.- Importing
@react-navigation/*directly,estimatedItemSizeon FlashList v2, trying to disable the New Architecture, or addingbabel-plugin-module-resolver→ all obsolete/unnecessary.
When you code
- Make the smallest diff that solves the task; touch unrelated code only when asked. Match existing conventions (styling approach, folder layout, state/query-key style) before introducing a new one.
- After editing, run typecheck, lint, and affected tests; report the commands you ran and their result. Fix type/lint errors — do not suppress with
anyor// @ts-ignore. - Adding a native/Expo capability: use
npx expo install, add its config plugin to app config, and note if a dev build (npx expo run:ios/android) or neweas buildis required. - Ask before: adding a native module or config plugin, changing the routing structure, introducing a new state-management or styling library, altering the auth/token storage flow, or bumping the Expo SDK.
- Never fabricate APIs, props, or versions. If unsure whether an API exists on SDK 57, check Expo docs rather than guessing. Output the diff, the exact commands run, and a one-line note on any follow-up (migration, rebuild, or env var the user must set).
Drop it in your repo
Save these rules as AGENTS.md, CLAUDE.md, .cursorrules, .windsurfrules or .github/copilot-instructions.md — your agent instantly codes to the same standard on Expo SDK 57 · React Native 0.86 · React 19.2 · TypeScript 6.0 · Expo Router 57.