Promptheus/rules53 rule sets · CC0Promptheus hub ↗

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.

react-nativeexpomobiletypescript

Updated 5 Jul 2026 · CC0

AGENTS.mdrepo root

You 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 strict mode. Install every native/Expo dep with npx expo install <pkg> so versions stay SDK-aligned — never hand-pick RN-native versions in package.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: true in app config) and use the generated Href type.
  • Data fetching: TanStack Query v5 (@tanstack/react-query 5.101.x). No manual useEffect + fetch + useState triads for server data.
  • Lists: @shopify/flash-list v2.3.x (FlashList) for anything scrollable and unbounded; FlatList is the fallback. v2 is New-Arch-only and needs no size estimates. Never ScrollView + .map() for dynamic/long data.
  • Styling: StyleSheet.create or 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-storage for tokens.
  • Images: expo-image (Image from expo-image), not RN core Image, for caching + contentFit.
  • Animation/gesture: react-native-reanimated 4.5 (the worklet runtime now ships as the separate react-native-worklets 0.10 peer dep) + react-native-gesture-handler 2.32. Animate on the UI thread with worklets, not Animated + useState.
  • Safe area: react-native-safe-area-context (SafeAreaProvider, useSafeAreaInsets). The RN-core SafeAreaView is 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 let eslint-config-expo's bundled compiler rules flag violations. It auto-memoizes components/hooks, so stop hand-wrapping everything in useMemo/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 in src/. Keep non-route code out of app/ 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, hooks useThing.ts, other modules camelCase.ts. Route files follow Router conventions ([id].tsx, (group)/, _layout.tsx) with a default export.
  • Imports: declare the @/*src/* alias in tsconfig paths only — Expo's Metro resolves paths natively (SDK 49+), so babel-plugin-module-resolver is redundant and should not be added. No deep relative chains (../../../).
  • Typed props always: type Props = { ... } then function Card({ title }: Props). Skip React.FC; type children: ReactNode explicitly when accepted, so every prop is visible in one place. No any — use unknown + 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.tsx renders <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 bare string.
    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) over useGlobalSearchParams, 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. Define renderItem outside the component or wrap in useCallback; never pass an inline arrow that closes over changing state as renderItem.
    const keyExtractor = (item: Todo) => item.id;
    const renderItem = useCallback(
      ({ item }: { item: Todo }) => <TodoRow todo={item} onToggle={toggle} />,
      [toggle],
    );
    
  • Row components (TodoRow) should be React.memo with a stable onToggle so 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. Reserve useEffect for external subscriptions/imperative side effects, and always return its cleanup (subscriptions, listeners, timers, in-flight work).
  • Use Pressable for touchables (not the legacy TouchableOpacity/TouchableHighlight).

Styling

  • StyleSheet.create once at module scope — never build a style object inside render or renderItem (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 className with shared tailwind.config.js tokens; wire it through metro.config.js (withNativeWind) and import global.css once in the root layout. Keep conditional classes in a helper (clsx/cva); don't concatenate raw strings ad hoc or mix ad-hoc inline style with utilities for the same concern.
  • Layout with flexbox and relative units; avoid hardcoded pixel widths/heights for containers. Use gap for spacing between flex children. Theme via useColorScheme() + a tokens object (or NativeWind dark: variants) — no hex scattered across components.

Lists and performance

  • Long/unbounded lists: FlashList (v2 auto-sizes — omit estimatedItemSize) with a stable keyExtractor and a memoized renderItem. Use FlatList only if FlashList is unavailable, and .map() in a ScrollView only for a small, bounded set of heterogeneous children.
  • keyExtractor must return a stable, unique id — never the array index (breaks recycling and item state on reorder/insert). For mixed row shapes pass getItemType so 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 FlatList fallback, tune windowSize, maxToRenderPerBatch, initialNumToRender, and getItemLayout for fixed-height rows; enable removeClippedSubviews on Android for very long lists.
  • Keep heavy CPU work (JSON parsing, crypto, large reduces) off the JS thread. InteractionManager.runAfterInteractions does 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 a react-native-worklets worker 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-image with cachePolicy and contentFit for 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 SafeAreaProvider and read insets via useSafeAreaInsets() (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 (KeyboardAvoidingView behavior differs by OS, or react-native-keyboard-controller). Test both platforms before claiming done.

Accessibility

  • Every interactive element needs a role and a name: accessibilityRole="button" (or link/header/image) plus accessibilityLabel, and accessibilityHint when the action isn't obvious. Icon-only Pressable is 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 hitSlop instead of padding so the tappable area grows without changing the visual size.
  • Group compound content with accessible on the wrapper (reads as one node) and hide decorative nodes via importantForAccessibility="no-hide-descendants" / accessibilityElementsHidden. Label meaningful images; leave decorative ones unlabeled.
  • Respect Dynamic Type: leave allowFontScaling on (default). Cap runaway growth on dense UI with maxFontSizeMultiplier rather than disabling scaling; size rows in relative units. Gate non-essential animation behind Reanimated's useReducedMotion(). 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 QueryClient at app root via QueryClientProvider in the root _layout.tsx. Server state lives in Query; client-only UI state in useState/Context/Zustand. Do not mirror fetched data into useState or store it in Context.
  • Centralize typed keys and queryFn with queryOptions factories: ['todos', { status }]. Reads use useQuery (status is pending/error/success); writes use useMutation (isPending) with onSuccessqueryClient.invalidateQueries({ queryKey: ['todos'] }), or optimistic updates with rollback in onError.
  • Set sensible staleTime/gcTime; render isPending/isError — surface a retry affordance, never swallow errors. Paginate with useInfiniteQuery (+ getNextPageParam) wired to onEndReached; gate dependent queries with enabled instead of nesting fetches.
  • Wire RN lifecycle so caching works: refetch on foreground via AppStatefocusManager, and pause/resume on connectivity via @react-native-community/netinfoonlineManager.
    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-store only (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-sqlite only for non-sensitive cache, prefs, and offline data. Gate sensitive unlocks with expo-local-authentication (biometrics).

Testing

  • Unit/component: Jest with the jest-expo preset + @testing-library/react-native 14. Query by role/text/label (getByRole('button', { name }) doubles as an a11y check); avoid testID unless there's no accessible handle. Prefer userEvent over raw fireEvent.
  • 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 fresh QueryClientProvider per test with retry: false, plus the safe-area provider, via a custom render.
  • Test hooks with renderHook. Keep pure logic (validation, formatting, reducers) in src/lib and 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 in expo-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. In WebView, set originWhitelist and never feed untrusted input into injectedJavaScript.
  • Keep the New Architecture and SDK current for security patches; pin deps and run npx expo-doctor + npm audit before release.

Do

  • Run npx expo start, npx tsc --noEmit, npx expo lint, and jest before saying a change is done.
  • Keep the New Architecture on; use npx expo install (not bare npm 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.push with typed params.
  • Use FlashList with a stable keyExtractor and hoisted/memoized renderItem; put animations on worklets.
  • Store tokens in expo-secure-store; server state in TanStack Query; handle pending/error on every async surface.
  • Ship OTA-safe JS/asset changes via eas update; bump native builds through eas build when 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 or useCallback, and React.memo the row.
  • Secrets in AsyncStorage/MMKV or EXPO_PUBLIC_*expo-secure-store / server-side.
  • Manual fetch + useState + useEffect for server data → TanStack Query.
  • RN-core SafeAreaView / Image / TouchableOpacity → safe-area-context insets / expo-image / Pressable.
  • Style objects built in render → StyleSheet.create at module scope + style arrays.
  • Animated + state-driven animation on the JS thread → Reanimated worklets.
  • InteractionManager.runAfterInteractions as "offloading" → it stays on the JS thread; use a worker runtime/native module or chunk the work.
  • React.FC, any, disabled strict, syncing derived state via useEffect.
  • Importing @react-navigation/* directly, estimatedItemSize on FlashList v2, trying to disable the New Architecture, or adding babel-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 any or // @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 new eas build is 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.

Back to top ↑