React Native guidelines
We build mobile apps with React Native through Expo (managed workflow, Expo Router, New Architecture, React Compiler). This guide only covers what is specific to React Native and Expo. Everything else (TypeScript, function components, named exports, prop types, composition, state management, data fetching, ESLint) is the same as in our React guidelines: read that guide first and come back here for the mobile-specific parts.
3.3. Styling and theming
3.5. REST API client
3.6. Forms
3.7. Internationalization
3.9. Testing
3.12. Third-party SDKs
1. Do's and Don'ts
1.1. Follow the React guidelines
Everything in the React guidelines applies unless this guide says otherwise. In particular:
Use TypeScript and the TypeScript guidelines
State management: hooks for local state, TanStack Query for server state, Context for simple shared state, zustand for complex client state, no redux
External services and REST: generated clients over hand-written ones
React 19 and React Compiler: no hand-written
useMemo,useCallbackormemo(see Server and client components; the Server Components part does not apply, every React Native component is a client component)
The exception to the named exports rule is Expo Router: route files in src/app/ require a default export, the same way Next.js special files do.
1.2. Use Expo, managed workflow
Always start from Expo: bun create expo-app@latest (the default template ships TypeScript, Expo Router and the src/ folder question). Bare React Native (react-native init, now @react-native-community/cli) is only for projects that already exist in that shape or that need native code Expo cannot express with a config plugin, which is rare. We use bun as the package and script manager: bun install, bun add, bun run <script> and bunx <cli> instead of their npm counterparts.
Expo gives us file-based routing, a versioned SDK that keeps native modules compatible, over-the-air updates, EAS builds and a dev client, and it lets the team ship without opening Xcode or Android Studio.
1.3. Never touch the native folders
The android/ and ios/ folders are generated by prebuild and are in .gitignore. Never create, edit or commit them. All native configuration goes through app.json (or app.config.ts) and config plugins: icons, splash screen, permissions, deep link scheme, bundle identifiers, orientation.
If a library asks you to edit AndroidManifest.xml, Info.plist or a Gradle file, look for its config plugin first: the library itself, then the community @config-plugins/* packages. Write your own config plugin if there is none. Build settings that Expo does not expose (iOS deployment target, Kotlin version, ProGuard) go through expo-build-properties, not through the native folders. Ejecting to a bare workflow is a team decision, not a shortcut.
1.4. Install native packages with expo install
Any package that contains native code goes in with bunx expo install <pkg>, so Expo picks the version compatible with the current SDK. Pure JavaScript packages go in with bun add as usual.
After adding, removing or upgrading dependencies run bunx expo-doctor, and fix version drift with bunx expo install --fix.
When a package must stay pinned outside the SDK's recommended version (a Reanimated major the app is not ready for), declare it in package.json under expo.install.exclude and say why in a comment next to the dependency or in CLAUDE.md. The same goes for expo.doctor.reactNativeDirectoryCheck.exclude. A pin without a reason gets "fixed" by the next expo install --fix.
1.5. Read the versioned Expo docs
Expo changes a lot between SDKs and the training data of AI assistants is usually behind. Before using an unfamiliar API, read the docs for the exact SDK the project uses (for example https://docs.expo.dev/versions/v57.0.0/), never the "latest" page. Put that URL in the project's CLAUDE.md so assistants read it too.
1.6. Only route files live in app/
src/app/ is Expo Router's territory: every file there is a screen or a layout. Keep those files thin. They read route params, and render a screen component from src/components/<feature>/. Components, hooks, queries and types live in their own top-level folders (see 2) and are imported into the route file.
This is the mobile equivalent of the "only Next.js special files sit directly in a segment folder" rule from the React guide. A route file that grows useState, data hooks or JSX beyond a wrapper is a sign that a screen component is missing: move that code to src/components/<feature>/ and leave the route file as a one-liner.
1.7. Style with NativeWind, not StyleSheet
Style exclusively with NativeWind className utilities on top of the gluestack-ui primitives (see 3.3). No StyleSheet.create, no inline style objects, except for values that cannot be expressed as utilities: Reanimated animated styles, color props of native components (SymbolView tints, native tab colors) and third-party components that only accept style.
Theme tokens (colors, spacing, fonts) live in global.css following the Tailwind v4 architecture. Don't duplicate them in a tailwind.config.js theme block or in JavaScript constants.
1.8. Translate every user-facing string
All copy goes through react-i18next, from day one, even when the app ships in one language. Hardcoded user-visible strings in components are forbidden. English is the source language. See 3.7.
1.9. Use kebab-case file names
Expo's template uses kebab-case for every file (post-detail.tsx, use-color-scheme.ts, _layout.tsx), and file-based routing turns file names into URL segments. Follow it across the whole project, components included, instead of the PascalCase file names of our web projects. Component and hook identifiers stay PascalCase and camelCase as usual.
1.10. Lint with expo lint and ESLint Stylistic
Use eslint-config-expo/flat as the base (it includes eslint-plugin-react-hooks and the Expo rules) plus ESLint Stylistic for formatting, exactly as in the React guidelines. No Prettier. Run it through bun run lint (expo lint), which wires the Expo defaults. Ignore src/lib/api/generated/**, android/**, .expo/** and coverage/**.
Add three more plugins on top of the Expo config: @tanstack/eslint-plugin-query (catches wrong query keys and unstable deps), eslint-plugin-react-you-might-not-need-an-effect (flags effects that derive state or sync props) and eslint-plugin-simple-import-sort (one import order, auto-fixed).
Generated gluestack components under src/components/ui/ are linted like any other code. They are ours.
2. Project organization and architecture
Same src/ conventions as the React guide: components/, hooks/, lib/, config.ts. Folders are grouped by role, not by feature: every top-level folder holds one kind of file, and inside each folder things are grouped by domain (posts, auth, cards). Routing lives in app/ following Expo Router's file conventions.
app/
Expo Router routes only: screens and _layout.tsx
Business logic, reusable components, data fetching
components/
All React components: ui/ primitives, shared/ app-level, one folder per route feature
Routes, pure utilities
hooks/
Reusable hooks; hooks/api/ for TanStack Query hooks
One-off logic that belongs in a component
stores/
zustand stores (session, wizards)
Server state
contexts/
React Context providers for rarely-changing app state
Server state, anything zustand should hold
lib/
Framework-agnostic code: API client, query client, i18n, icons
JSX
types/
Shared TypeScript types, grouped by domain
Runtime code
constants/
Static values grouped by domain
Logic, env reads
config.ts
The only reader of process.env
Anything else
Older projects were bootstrapped without src/: the same folders sit at the repository root and @/* maps to the root. Expo supports both layouts. Don't migrate an existing project just for this, but never mix the two in one repo.
2.1. Expo project structure
2.2. Components, hooks and domain folders
Route feature to component folder, 1:1. Every route feature under app/ has a matching folder under components/: app/(tabs)/posts/** renders from components/posts/**, app/(auth)/** from components/auth/**. Adding a screen means adding or extending its component folder. The screen component (post-list.tsx) is the thing the route file renders; it composes smaller components from the same folder and hooks from hooks/.
Three kinds of components.
components/ui/: design-system primitives with no domain knowledge (button, input, sheet, the form kit). gluestack-ui CLI output lives here. Re-exported fromcomponents/ui/index.ts, imported from the barrelcomponents/shared/: app-level components used by more than one feature but too specific forui/(Providers,AuthGuard,EmptyState,AppTabs)components/<feature>/: everything else, owned by one route feature
One folder per component, always. Every component lives in its own kebab-case folder containing a file of the same name (post-card/post-card.tsx). The folder is the unit of isolation: its spec, its zod schema, its styles helper and its local sub-components sit next to the component without cluttering the feature folder. Never drop loose .tsx files directly into components/<feature>/, shared/ or ui/.
Local sub-components stay local. A component used only by one parent goes in a components/ subfolder inside that parent's folder, following the same one-folder-per-component rule, not in the feature root or shared/:
Promote to the feature root when a second component in the feature needs it, to shared/ when a second feature needs it, and to ui/ only when it has no domain knowledge left. This is the nearest common ancestor rule from the React guide.
Mirrored grouping. hooks/api/, types/, constants/ and lib/api/ are grouped by the same domains as the route features (posts, auth, cards). A new type, constant or endpoint hook goes next to its domain siblings, so the domain is easy to grep across folders even though its files live in different roles.
2.3. Path aliases
Import from src/ with the @/ alias (@/components/..., @/hooks/api/...) and from assets/ with @/assets/.... Configure it in three places and keep them in sync: tsconfig.json (paths), babel.config.js (module-resolver) and the Jest moduleNameMapper.
2.4. Tooling
Git hooks: lefthook, installed on
bun installthrough thepreparescript. Pre-commit lints staged files with--fixand--max-warnings 0; pre-push runs typecheck, lint and tests with coverageCI runs the same three commands:
lint -- --max-warnings 0,typecheckandtest:covScripts to expect in every project (run with
bun run <script>):start,android,ios,web,lint,lint:fix,typecheck,test,test:cov,api:generateProjects with more than one service (the app plus its backend, a database, a mock server) run them together with mprocs: one committed
mprocs.yamlat the repository (or meta repository) root, one process per service, started withbun run dev. Every developer gets the same set of processes, one window, per-process logs and restarts. A single-service app doesn't need it:bun run startis enough
3. Common patterns
3.1. Routing with Expo Router
Expo Router owns navigation. It is file-based like Next.js App Router, so the mental model from the React guide carries over:
Files under
src/app/are screens, folders are segments,_layout.tsxwraps its children with a navigator (Stack,Tabs, orNativeTabs) and persists across navigation[id].tsxfor dynamic segments,[...slug].tsxfor catch-all,(group)for route groupsNavigation structure changes are file moves, not navigator config
Enable typed routes (
experiments.typedRoutes: trueinapp.json) and navigate withLinkandrouterusing typed hrefs. Never build route strings dynamically without the types catching it. Typed routes replace thesrc/routes.tsfile of our web projects: the generated.expo/types/router.d.tsis the list of routesRead params with
useLocalSearchParams<{ id: string }>()in the route file and pass them down as propsScreen titles are set in the segment
_layout.tsxwithStack.Screenoptions, translated withuseTranslation. Never hardcode a title string, not even in the root layoutSplit public and authenticated areas into route groups:
(auth)/for login, registration and onboarding,(tabs)/for the signed-in app. The(tabs)/_layout.tsxwraps itsTabsin anAuthGuardthat renders<Redirect>when there is no session (see 3.10).app/index.tsxonly decides where to redirectA tab that must exist as a route but not in the bar (a notifications list reached only from a bell icon) gets
href: nullin itsTabs.ScreenoptionsA screen that must react to becoming visible again (refresh a QR code, restart a timer) uses
useFocusEffectfrom Expo Router, notuseEffect. Screens in a stack stay mounted when covered, souseEffectdoes not fire on returnDeep and universal links: declare the
scheme,ios.associatedDomainsandandroid.intentFiltersinapp.json. A link opened while logged out must survive the login: store the target (apendingPostIdin a small zustand store, for example) and redirect to it after authentication instead of dropping the user on the home screen
The root _layout.tsx is where providers go, where initI18n() runs and where the splash screen is held until the app is ready. Rules for it:
Keep the provider stack in one
Providerscomponent (src/components/shared/providers/providers.tsx):QueryClientProvider,GluestackUIProvider,SafeAreaProvider,KeyboardProviderfrom react-native-keyboard-controller,ThemeProvider. The layout file stays readable and tests can reuse the same stackWrap everything in an
ErrorBoundarythat renders a translated fallback with a retry action. An uncaught render error must never leave the user on a blank screenCall
SplashScreen.preventAutoHideAsync()at module scope, load custom fonts withuseFonts(expo-font) and restore the session (see 3.10), then hide the splash screen once both are ready. Render nothing before that: a flash of the wrong screen is worse than a slightly longer splashInitialize third-party SDKs from one place at startup, not from screens (see 3.12)
The root layout gates the app in a fixed order before rendering the main navigator: backend reachable (a health request with a short timeout, otherwise a
network-errorscreen with retry), onboarding completed (a flag in AsyncStorage, otherwise(onboarding)), session present (otherwise(auth)), then(tabs). Put that order in one hook (useInitializeApp()), not spread over screens
3.2. Components and platform APIs
Images: expo-image (
Image), never React Native coreImage. Wrap it once withstyled(Image, { className: "style" })from NativeWind and reuse that wrapperLists: long or unbounded lists use
FlatList(or FlashList), never.map()inside aScrollViewAnimations: react-native-reanimated, not the core
AnimatedAPI. Gestures: react-native-gesture-handlerSafe areas: react-native-safe-area-context hooks and components, never hardcoded padding
Platform differences: when a component diverges entirely per platform, use platform extensions (
component.web.tsx,component.ios.tsx,component.android.tsx) next to the default file. Keep small inline differences inPlatform.selectorPlatform.OSchecksExternal links: open them with
expo-web-browseron native and letLinkbehave as an anchor on webScreens that show secrets or payment material (QR codes, card numbers, one-time codes) call
usePreventScreenCapture()from expo-screen-capture so they are blank in screenshots, recordings and the app switcherHaptics on meaningful actions (payment confirmed, code scanned) with expo-haptics, never on every tap
Follow the Rules of React strictly (no side effects during render, no mutating props or state): the React Compiler depends on them
3.3. Styling and theming
Component library: gluestack-ui v5, built on NativeWind v5 and Tailwind v4. Do not install the legacy
@gluestack-ui/themedpackagesAdd components with the CLI (
bunx gluestack-ui add <component>), never by hand-copying. Components are copied intosrc/components/ui/shadcn-style: they are ours to edit, so customize them in place instead of wrapping them in pass-through components. This mirrors the shadcn/ui setup of our web projectsRecurring visual variations become variants on the copied component (its
tvavariant definitions), not ad-hocclassNameoverrides at call sitesPrefer an existing gluestack component or a variant of one over a one-off custom component
Dark mode and theming go through the CSS variables in
global.css(:root,@media (prefers-color-scheme: dark),:root.darkand:root.lightfor the web class toggle), not through conditionalclassNamelogic in components. NativeWind maps the media query to the device appearance on nativeThe few native color props that cannot read CSS variables (
SymbolView, native tabs,tabBarActiveTintColor) read a smallColorstable insrc/constants/theme.tsthrough auseTheme()hook. Keep that table minimal and never paste a hex value into a navigator option or a componentIcons: lucide-react-native. Register every icon once with
cssInteropinsrc/lib/icons.ts(soclassName="text-muted-foreground"colours it) and import icons from that file, never from the package directlyCustom fonts: load them with
useFontsin the root layout (Google fonts through@expo-google-fonts/*, brand fonts fromassets/fonts/), register the families as theme tokens and use them through utilities (font-heading), never throughfontFamilyin a styleProjects still on NativeWind v4 keep their tokens in
tailwind.config.tsand use acn()helper (clsx plus tailwind-merge) for conditional classes. That is fine for them: don't move tokens toglobal.csspiecemeal. The upgrade to NativeWind v5 and gluestack-ui v5 is one dedicated change, not a side effect of a feature
3.4. Server state with TanStack Query
Same rules as in the React guide: TanStack Query v5 is the only manager for server state, and its cache is the global store for that data. Mobile additions:
One
QueryClientcreated insrc/lib/query.tsand provided at the root layout. No per-feature clientsWire
onlineManagerto @react-native-community/netinfo andfocusManagertoAppState, so refetch-on-reconnect and refetch-on-focus work on nativeEvery domain defines a query key factory (
postKeys), exported from the hook file that owns the root key. Raw inline keys at call sites are forbidden, and invalidation always goes through the factoryComponents never call
useQueryoruseMutationdirectly. Every query and mutation is a named hook in its own file undersrc/hooks/api/(use-posts.tsexportsusePosts(),use-create-post.tsexportsuseCreatePost()) that encapsulates key, query function and options. A mutation that must invalidate another domain imports that domain's key factory, so cross-domain cache wiring is explicit and greppableMutations invalidate or update the cache in
onSuccessoronSettledusing the key factory. Use optimistic updates where waiting for the network feels sluggish, and always implement the rollback inonErrorInfinite queries for long lists set
maxPagesto bound memoryQuery keys include everything that changes the response: the params, but also the current user id, the selected tenant or company and
i18n.languagewhen the API returns localized content. Switching account, company or language must never show another scope's cached dataQuery hooks accept an
optionsargument typed asPick<UseQueryOptions<...>, "enabled" | "placeholderData">(add only what callers need) so a screen can tune a query without the hook exposing the whole TanStack surfaceQueries that depend on a param the screen may not have yet (an
idfrom a deep link) setenabled: !!idinstead of guarding in the componentWhen one event changes several features at once (a new comment updates the post detail, the post list counters and the author profile), keep the list of affected key families in one helper (
src/lib/query/invalidation.ts) and call it from the mutation. Don't spreadinvalidateQueriescalls across hooks that then drift apart
3.5. REST API client
As in the React guide, generate the client from the backend's OpenAPI spec with Hey API into src/lib/api/generated/ (plugins: @hey-api/client-fetch, @hey-api/typescript, @hey-api/sdk, @tanstack/react-query). Never edit the output; regenerate with bun run api:generate. Mobile specifics:
src/lib/api/client.tsis the only hand-written configuration: base URL, auth header andAccept-Languageset to the device language so API error messages come back translatedIn development, default the base URL to the Expo dev server host read from
Constants.expoConfig?.hostUri. That way simulators, USB and Wi-Fi devices reach a backend running on the developer's machine with no configuration.EXPO_PUBLIC_API_URLonly overrides it (Android emulator without adb:http://10.0.2.2:<port>)Wrap the generated SDK per resource in
src/lib/api/<resource>.ts: re-export the DTO types under domain names (Post,CreatePostInput), call the generated functions withthrowOnError: trueand unwrapdata. Query hooks consume these wrappers, never the generated code directlyTypes flow from the API contract. Never retype responses locally
No OpenAPI spec, or backend not ready yet. Fall back to the repository pattern, one repository per domain, all under src/lib/api/:
src/lib/api/posts/posts.repository.tsdeclares the interface (IPostsRepository) in terms of the domain types fromsrc/types/posts/src/lib/api/posts/rest-posts.repository.tsimplements it against a small hand-writtenapiClient(src/lib/api/client.ts: base URL, auth headers, JSON, anApiErrorclass carryingstatusCodeandfieldErrors). Wire types (PostWire, snake_case, exactly what the backend sends) and themapPost(wire): Postfunctions live in this file and nowhere else. The rest of the app only sees camelCase entitiessrc/lib/api/posts/mock-posts.repository.tsimplements the same interface with fixtures and adelay(). It lets the UI ship before the backend exists and doubles as test dataOne typed registry (
src/lib/api/repository-registry.ts) maps domain keys to instances:getRepository("posts").getPost(id). Query hooks inhooks/api/call the registry, never a repository class, so switching from mock to REST is one line per domainMigrate to Hey API as soon as the backend publishes a spec. Wire types and mappers are exactly the code the generator would have written for you
3.6. Forms
react-hook-form plus zod through @hookform/resolvers, as on the web. React Native has no <form> element and no native inputs that understand onChange events, so we keep a small form kit in src/components/ui/form/:
Formrenders theFormProviderplus aBox. Submission is aButtonwhoseonPresscallsform.handleSubmit(onSubmit)()FormField,FormItem,FormLabel,FormControlandFormMessageare the primitives (same names as the shadcn/ui form recipe).FormControlinjectsisInvalidinto the gluestack input, so new wrappers around gluestack widgets should accept that propField components (
FormInput,FormTextarea,FormButtonGroup) compose the primitives with the gluestack inputs and bindvalue,onChangeTextandonBluruseZodForm(schema, options)insrc/hooks/wrapsuseFormwith the zod resolver, typed with the schema's input and outputValidation messages are translated. Install zod-i18n-map as the global zod error map at i18n init so zod's built-in messages (required, too short, invalid email) come out in the user's language for free. Custom messages still go through
t: schemas are factories takingt(postSchema(t)), built inside the component, with keys under<feature>.form.errors.*Single-choice fields with few options (statuses, roles) use
FormButtonGroupwith translated labelsType the schema against the API input type with
satisfies z.ZodType<CreatePostInput>so the form and the contract cannot driftNo English messages inside schemas. A shared
validation.tsfull of"Password is required"strings is a bug under the i18n rule even when the app ships in English only: use thetfactoryServer-side validation errors get the same treatment as client-side ones. Map the
fieldErrorsof anApiErroronto the form in one helper (mapApiError(error, t, { fieldMap })): known fields go toform.setError(field, ...),baseand unknown fields become a single global message shown above the submit button. Backend error symbols (taken,invalid) are translated through anerrors.<symbol>key family, never shown rawText inputs declare their intent:
keyboardType="email-address",autoCapitalize="none",autoCorrect={false},secureTextEntry,textContentType. It is the difference between a native-feeling form and a web form in a wrapperThe submit button calls
Keyboard.dismiss()before submitting, and forms live inside a keyboard-aware scroll view (react-native-keyboard-controller) so the focused field is never hidden behind the keyboard
3.7. Internationalization
react-i18next with bundled JSON catalogs. Rules:
The i18n instance lives in
src/lib/i18n/and is created withcreateInstance(), not the implicit global.initI18n()runs once at the top of the root layoutLanguage comes from the device (expo-localization
getLocales()), withenas fallback for unsupported languages. Never hardcode a language at call sitesCatalogs are JSON files under
src/lib/i18n/locales/, bundled with the app. No async backendsen.jsonis the source of truth. Every other locale mirrors its key set exactly, enforced by a key-parity spec. Adding or removing a key touches every catalog in the same changeKeys are snake_case, nested by feature (
posts.form.title,posts.detail_title,common.retry), shared copy undercommon. Key names describe meaning, never the English textKeys are typed:
i18next.d.tsaugmentsCustomTypeOptionsfromen.json, sot()and<Trans>reject unknown keys. Props that carry a key are typedParseKeys, notstringRead translations with
useTranslation()inside components. Never calli18n.t()in render code and never resolve translations at module scopeOne key per complete sentence. Dynamic values use interpolation, countable copy uses plurals, inline elements use
<Trans>with namedcomponents. Never build sentences by concatenating keysEnum-like values rendered to users map through a key family (
posts.status.<value>). Never render the raw valueDates and numbers are formatted with the active language (
i18n.language), never the bare device defaultNot translated: code, commands, file paths, scientific names, brand names, keyboard shortcuts, version strings
Native strings are translated too: permission usage descriptions (
NSLocationWhenInUseUsageDescription) and the app display name go throughexpo.localesinapp.json, one JSON per language undersrc/lib/i18n/native/. SetCFBundleAllowMixedLocalizationsso iOS picks them upNamespaces are optional. A big app can split
en.jsoninto one i18next namespace per feature pluscommonanderrors, read withuseTranslation("posts"). Pick nested keys or namespaces at the start and stay with it. Either way the key casing is snake_case, and the typed keys and parity spec above still applyTests run the real i18n with the English catalog (see 3.9). Don't mock
react-i18nextto return keys: it hides missing keys and interpolation bugs, and it makes assertions read likeexpect(getByText("auth:sign_in"))
3.8. Configuration and environment
Runtime config comes from
app.jsonthrough expo-constants. Static config stays inapp.json. When native config needs values from the environment (Google Maps keys per platform, the path togoogle-services.json), switch toapp.config.ts, spread the static config and override only those fieldsRead
process.env.EXPO_PUBLIC_*in exactly one file,src/config.ts, which exports a typedconfigobject (booleans parsed, defaults applied). Components and hooks import from it; the stringprocess.envdoes not appear anywhere else. This is the samesrc/config.tsas in the React guideEnvironment variables exposed to the app must use the
EXPO_PUBLIC_prefix. Anything without it is unavailable in app code by design: never work around that. Anything with it ships in the bundle, so it is never a secret. Real secrets stay on the backendEXPO_PUBLIC_*values load when the dev server starts: restart Expo after editing.env. Commit a.env.examplethat documents every variableEnable
experiments.typedRoutesandexperiments.reactCompilerinapp.jsonWeb output:
web.output: "single"(SPA) while NativeWind v5 does not support Expo's static SSR. Revisit when it doesPatched dependencies live in
patches/(bunpatchedDependencies). Keep the patch when bumping the package and document it in the project'sCLAUDE.mdProject-specific config plugins live in
plugins/and are referenced fromapp.jsonby relative path ("./plugins/with-android-manifest-fix"). Keep them tiny and commented: they run on every prebuild and are the only native code in the repoNative permissions are declared in
app.jsonwith a user-facing reason (ios.infoPlist.NSPhotoLibraryUsageDescription) and requested lazily, right before the feature that needs them. Never request all permissions at startupApps that QA tests on real devices ship a hidden developer menu behind
EXPO_PUBLIC_ENABLE_DEV_TOGGLES(true indevelopmentandpreviewbuilds, false inproduction): a long press on the tab bar opens a sheet with persisted toggles such as API request logging, mocked location or data, and skipping SMS or OTP validation. Every toggle reads from onedevTogglesstate and defaults to off, and none of that code is reachable when the flag is falseWire @dev-plugins/react-query in the root layout so the TanStack Query cache is inspectable from the Expo dev tools
Ship the third-party licenses: generate
assets/third-party-licenses.txtwith generate-license-file on every dependency change and show it from a screen in settings. Some clients and stores require it
3.9. Testing
Runner: jest-expo with React Native Testing Library. Config lives in the
jestblock ofpackage.jsonCoverage is collected from the trees where logic lives (
src/lib,src/hooks,src/stores,src/contexts) with a 100% threshold.src/app/**(route wiring) andsrc/components/**(generated primitives, navigation and animation wiring) are excluded by scope, although presentational components incomponents/<feature>/andcomponents/shared/still get colocated specs. New logic goes in a collected tree with its spec; never park logic under an excluded tree to dodge coverageSpecs sit next to their source (
post-list.spec.tsx)Query hooks are tested with
renderHookand a freshQueryClient(retry: false) per test, mocking thesrc/lib/api/<resource>wrapperScreen components are tested by mocking the
hooks/apihooks they use and asserting on rendered English copy. A setup file (test/setup-i18n.ts, wired throughsetupFilesAfterEnv) mocksexpo-localizationand initializesenCSS imports are stubbed in
moduleNameMapperNative modules that don't run under Jest (secure store, notifications, purchases, analytics SDKs) are mocked once in the setup file, not in every spec. Anything mocked there needs its own contract test elsewhere
Shared test helpers (
createTestQueryClient(),renderHookWithProviders()) live intest/and are excluded from test discovery withtestPathIgnorePatternsRepository implementations (see 3.5) are unit tested against a mocked
fetch: wire fixture in, domain entity out. Hooks are tested with the repository mocked, so each layer is covered onceEnd-to-end tests on device are optional and project-specific (Maestro is the tool to evaluate). The general rules from our Testing guidelines still apply: critical paths first, pure business logic as unit-tested functions
3.10. Authentication and secure storage
Tokens live in expo-secure-store (Keychain on iOS, Keystore on Android). Never in AsyncStorage, never in a zustand
persistmiddleware backed by AsyncStorage. AsyncStorage is for non-sensitive preferences (onboarding seen, last tab)The session (
user,tokens,isAuthenticated) is a small zustand store (src/stores/auth.store.ts). It is the only client state that is read from almost everywhere, which is exactly the zustand case from the React guideOne
bootstrapAuth()runs at startup before the splash screen hides: read the stored session, validate its shape, hand the tokens to the API client and hydrate the store. A half-present session (tokens without user or the reverse) is discarded, not repairedThe store is the source of truth and storage follows it: one subscription persists token and user changes to secure storage and pushes tokens to the API client. Screens call
login()orlogout()on the store and never touch storage directlyThe API client owns token lifecycle details: it attaches the headers, captures rotated tokens from responses and, on a
401while holding tokens, clears them and notifies the store so theAuthGuardredirects. No screen handles401With access and refresh tokens, refresh before the request when the access token is expired (store the expiry with the tokens) instead of waiting for a
401. Refreshes are single-flight: one in-flight refresh promise, and every caller that arrives meanwhile awaits it, so ten queries mounting together produce one refresh, not ten. Debounce the "session expired" handling too: when a token dies, every in-flight query fails at once and the user must see one toast and one redirectRole and permission checks live in one
usePermissions()hook that derives booleans (canManageCards,canInviteUsers) from the current user and the selected company. Screens and components read those booleans; they never compare roles themselvesLogout clears the store, secure storage, the TanStack Query cache (
queryClient.clear()) and the identity of every third-party SDK, in oneclearLocalSession()helperSign-up flows with several steps (account, membership, connect a provider) persist the current step next to the session, so a killed app resumes where the user left off instead of restarting registration
3.11. Builds and releases with EAS
Builds, store submission and over-the-air updates go through EAS. Nobody builds release binaries on a laptop
Triggering is a different matter. EAS can start builds automatically only through its GitHub integration. When the client hosts the code elsewhere (GitLab, Bitbucket, Azure DevOps, a self-hosted Forgejo), release builds are triggered locally with
bunx eas build --profile production --platform all(andbunx eas submit) from a clean checkout of the release tag. The build still runs on EAS servers; only the trigger is local. Document the release steps in the project'sREADME.md, and keepeas.jsonas the single source of truth so a local trigger and a GitHub trigger produce the same binaryeas.jsondeclares three profiles:development(developmentClient: true,distribution: "internal"),preview(internal distribution for testers) andproduction(autoIncrement: true). Add anios-simulatorprofile thatextendspreviewwithsimulator: truefor reviewers without a deviceEXPO_PUBLIC_*values that differ per environment (API URL, third-party public keys) are set per profile ineas.jsonenv, not in committed.envfiles..envis for the developer's machine onlycli.appVersionSourceisremote: EAS owns the build number,app.jsonversionis the marketing version and is bumped by hand in a release commitEveryday development uses a development build (
expo-dev-client), not Expo Go, as soon as the app has a dependency with native code Expo Go doesn't ship. Expo Go stays useful for quick UI work, and every SDK wrapper must tolerate its native module being absent (see 3.12)Pin the EAS image per platform (
ios.image: "sdk-57") so builds don't change under you when Expo publishes a new defaultNative builds for a physical test device don't need the cloud:
bunx eas build --local --profile preview --platform androidproduces the same binary on a laptop, without spending build minutes. Release builds still go through the cloudSecret files that native builds need (
google-services.json,GoogleService-Info.plist) are not committed. Each developer copies them from the password manager,.gitignoreexcludes them, and EAS gets them as file environment variables (GOOGLE_SERVICES_JSON) read fromapp.config.tsAdd a
submitprofile toeas.json(App Store Connect app id, Play track) and expose the two release commands as scripts:deploy:previewanddeploy:production(eas build --profile production --auto-submit). Releases are a documented script, not a remembered commandA non-GitHub CI can still queue builds with a robot
EXPO_TOKENandeas build --non-interactive --no-wait. Use it only when the client asks for automated builds; the default for those hosts is the local trigger described above, documented inREADME.md
3.12. Third-party SDKs
Analytics, marketing, payments and crash reporting SDKs are the code most likely to break a build, crash Expo Go or leak into every feature. Rules:
One wrapper module per SDK (
src/lib/analytics.ts,src/lib/purchases.ts) exposing the three or four functions the app needs (initialize,identifyUser,reset). Nothing else imports the SDK packageConfigure them in the
Providersmodule at startup, guarded by aconfiguredflag, and no-op with a__DEV__warning when the public key for the platform is missingGate on the native module being linked (
NativeModules.X != null) and lazy-requirethe SDK inside the wrapper when its package reads native constants at import time. In Expo Go the wrapper becomes inert instead of crashing the app at bootIdentify the user after login, registration, password reset and session restore, from the auth flow, in one place. Reset the identity on logout
Anything that touches purchases goes behind a repository interface (
IBillingRepository), so the paywall UI does not know it is talking to RevenueCat and tests never load the SDKSDK calls are fire-and-forget from the user's point of view: never gate login or navigation on an analytics or marketing call succeeding
4. Libraries
4.1. Recommended libraries
Framework: Expo (managed workflow, New Architecture)
Routing: Expo Router with typed routes
Components: gluestack-ui v5 (copied into
components/ui/)Styling: NativeWind v5 on Tailwind v4
Images: expo-image
Animations and gestures: react-native-reanimated, react-native-gesture-handler
Safe areas: react-native-safe-area-context
Server state: TanStack Query v5, with @react-native-community/netinfo for online status
Typed REST client: Hey API
Forms: react-hook-form with zod and
@hookform/resolversInternationalization: react-i18next and expo-localization
Client state: zustand when Context is not enough (same criteria as the React guide)
Secure storage: expo-secure-store for tokens; @react-native-async-storage/async-storage for non-sensitive preferences only
Icons: lucide-react-native
Keyboard: react-native-keyboard-controller
Builds and releases: EAS Build, Submit and Update, with expo-dev-client for development builds
Testing: jest-expo, React Native Testing Library
Lint and format:
eslint-config-expo, ESLint Stylistic,@tanstack/eslint-plugin-query,eslint-plugin-react-you-might-not-need-an-effect,eslint-plugin-simple-import-sort
4.2. Other libraries we have used
Push notifications and marketing: expo-notifications, Klaviyo (through its Expo config plugin)
In-app purchases and subscriptions: RevenueCat (
react-native-purchases,react-native-purchases-ui)OAuth flows with third-party providers: expo-auth-session and expo-web-browser
SVG assets as components: react-native-svg with
react-native-svg-transformerBottom sheets: @gorhom/bottom-sheet
PDF viewing and downloads: react-native-pdf and react-native-blob-util, both through
@config-plugins/*
4.3. Libraries worth taking a look into
FlashList for long lists
Maestro for end-to-end tests on device
expo-sqlite with Drizzle for local persistence
5. Learning resources
Expo docs, always for the SDK you use: https://docs.expo.dev/
Expo Router: https://docs.expo.dev/router/introduction/
React Native docs: https://reactnative.dev/docs/getting-started
NativeWind: https://www.nativewind.dev/
gluestack-ui: https://gluestack.io/ui/docs
TanStack Query React Native notes: https://tanstack.com/query/latest/docs/framework/react/react-native
Last updated
