>_devkit
catalogue
Stablev1.1.0

react-native-expo

by @chempa

Use when building, structuring, or reviewing a React Native Expo app - folder layout, the atomic component tiers, hooks, state and data, tokens, constants, performance, and EAS environments.

React Native and Expo at scale

Practices for Expo apps that a studio ships repeatedly: many clients, many codebases, one set of habits. The goal is that app number seven is navigable by someone who only ever worked on app number one.

Two things make a React Native codebase unmaintainable, and neither is performance:

  1. Nobody can say where a new file goes. Every developer invents a folder,

so the same concept lives in four places.

  1. UI components learn the business. A Card grows a if (order.status)

branch, and now it cannot move to the next project.

Everything below exists to prevent those two.

The stack

Pick the same one every time. Deviating costs more than any individual library is worth.

ConcernChoice
FrameworkExpo SDK 55 (React Native 0.83, React 19.2), New Architecture only
RoutingExpo Router v7, typed routes on
LanguageTypeScript, strict: true, no any in reviewed code
Server stateTanStack Query
Client stateZustand, one store per feature
Formsreact-hook-form + zod, one schema shared with the API types
ListsFlashList v2
AnimationReanimated (worklets) + Gesture Handler
StorageMMKV for state and cache, expo-secure-store for secrets
StylingUnistyles, or NativeWind when the team also ships web Tailwind
TestingJest + React Native Testing Library, Maestro for journeys
ErrorsSentry, with a root and per-screen error boundary

The two axes

Most confusion about atomic design comes from asking it to do a job it was never designed for. It answers one question:

How reusable is this piece of UI?

It does not answer who owns this logic. That is feature slicing. The two are orthogonal, and a scalable app needs both:

                     shared, no domain knowledge
                                ^
   tokens -> atoms -> molecules | templates
   ------------------------------------------------  the line
                    organisms -> screens
                                v
                     owned by exactly one feature

Everything above the line lives in src/ui/ and could ship to any client tomorrow. Everything below lives in src/features/<name>/ and is disposable when the feature is. Organisms are where the domain enters the UI, and that is why they belong to features, not to the design system. Putting organisms in a shared folder is the single most common way an atomic codebase rots.

The knowledge ladder

Tiers are not about size or complexity. A 300-line date picker is still an atom; a 12-line <UserBadge user={user} /> is an organism. The tier is decided by what a component is allowed to know.

TierMay importMay knowNever
tokensnothingraw valuesany component
atomstokens, RN primitivesits own propsany other component of yours
moleculestokens, atomsits own propsdomain nouns, stores, queries, navigation
organismsanything above, feature hooksone domain conceptanother feature's internals
templatestokens, atoms, moleculeslayout and slotsdata, domain nouns
screensanythingrouting, orchestrationbeing imported by anything but its route

Two tests settle almost every argument:

The atom test. An atom imports zero components from your own codebase. The moment it imports another atom, it is a molecule. This is mechanical, so it does not need a meeting.

The domain test. Write the prop names out loud. If you cannot name them without using a word from the client's business (invoice, patient, shipment, booking), it is an organism. <Field label value onChange error /> is a molecule. <InvoiceRow invoice /> is an organism, even though it is smaller.

Full per-tier rules, a where-does-X-go lookup table, and the promotion and demotion rules are in references/tiers.md. Read it before classifying anything.

Structure

src/
  app/            Expo Router. Routes only. Thin re-exports plus screen options.
  ui/             The design system. tokens, atoms, molecules, templates.
  features/       One folder per feature. organisms, screens, hooks, api, store.
  lib/            Cross-cutting infrastructure: api client, storage, i18n, auth.
  hooks/          Generic hooks with no domain knowledge.
  config/         Validated env and runtime configuration.

The rule that keeps app/ honest: a route file contains no UI. It exports a screen from a feature and configures the route.

// app/(app)/invoices/[id].tsx
export { InvoiceDetailScreen as default } from '@/features/invoices/InvoiceDetailScreen';
export const unstable_settings = { initialRouteName: 'index' };

Routes are a URL surface, not a code organisation scheme. Keeping screens out of app/ means you can test a screen without a router, mount it at two routes, and restructure navigation without moving a single component.

Naming, path aliases, import direction, and the ESLint rules that enforce the boundaries are in references/structure.md.

No barrel files. Import from the file, not from an index.ts. Barrels cause circular imports, defeat Metro's already weak tree shaking, and make Fast Refresh reload half the app on a one-line style change. The convenience is not worth it, and autocomplete on a path alias covers most of the loss.

Constants: five kinds, five homes

"Constants" is not one thing, and a single constants.ts is where a codebase goes to die. Sort by lifetime and blast radius:

KindHomeNote
Design tokenssrc/ui/tokens/colour, spacing, radius, type scale, motion
Runtime configsrc/config/env.tsparsed with zod at boot, crashes loudly if wrong
Storage keyssrc/lib/storage-keys.tsone file, global, because collisions are silent
Query keyssrc/features/<f>/queryKeys.tsa factory per feature, never string literals
Domain enumswith the feature that owns thempromote to lib/ only on the third consumer

Route names are not a constant. Expo Router's typed routes already give you compile-time checking, so a hand-rolled ROUTES object only adds a second source of truth that can drift.

State: pick by lifetime, not by habit

Most state arguments dissolve once you ask how long the value needs to live.

  • Lives in one component -> useState. Do not lift it early.
  • Comes from the server -> TanStack Query. It is not "global state", it is a

cache, and treating it as state is what produces the sync bugs.

  • Client-owned, app-wide -> Zustand. Auth session, theme, onboarding flags.
  • Survives restarts -> MMKV. Secrets go to expo-secure-store instead.
  • Belongs to a form -> react-hook-form. Never mirror form fields into a store.

The failure mode to watch for: server data copied into a Zustand store "so other screens can read it". Now you own invalidation, and you will get it wrong. Details, plus the hooks taxonomy and rules, in references/state.md.

Hooks

Four kinds, and they do not live together:

KindExampleHome
UI behaviouruseDisclosure, useDebouncedValuesrc/hooks/
PlatformuseAppState, useKeyboardHeightsrc/hooks/
DatauseInvoices, useCreateInvoicefeatures/<f>/api.ts
Feature logicuseCheckoutFlowfeatures/<f>/

Non-negotiables: one concern per hook, no useMount-style lifecycle wrappers, no use prefix on a function that calls no hooks, and no hook that reads a store or a query inside an atom or molecule. That last one is what silently converts a shared component into a feature-coupled one.

Performance

The order that actually matters, now that the New Architecture is the only architecture in SDK 55:

  1. FlashList v2 for any list longer than a screen. v2 dropped

estimatedItemSize and friends, so remove them on migration. Give getItemType for heterogeneous rows.

  1. Animations in Reanimated worklets, never in setState on a scroll or

gesture handler.

  1. Stop hand-memoizing. React Compiler is on by default in Expo SDK 54 and

later. Adding useMemo, useCallback, and React.memo by reflex now costs readability and buys nothing. Reach for them only after a measurement.

  1. expo-image, not Image, for anything remote or cached.
  2. Measure before believing any of this. The list is the usual culprit; your

app might be the exception.

Testing, accessibility, security, and observability are in references/quality.md. EAS environments, app variants, updates, and the multi-client monorepo layout are in references/platform.md, and the styling and token system in references/styling.md.

The review checklist

Reject a merge request that does any of these:

  • A component in src/ui/ imports from src/features/
  • An atom imports another component of yours
  • A component prop is named after the client's domain, in src/ui/
  • A route file in app/ contains JSX beyond a layout navigator
  • A hex colour, font size, or spacing number appears outside src/ui/tokens/
  • Server data is copied into a Zustand store
  • A token, refresh token, or PII is written to MMKV or AsyncStorage
  • A new index.ts barrel
  • An interactive element without accessibilityLabel and accessibilityRole
  • An emoji used as an icon
  • An em-dash anywhere
  • any, or a @ts-expect-error without a comment naming the ticket

Maintainability

This skill opens by naming the two things that make a React Native codebase unmaintainable. Three more are worth stating, because they are what actually kills apps a studio has to keep alive across years and clients.

Falling behind on the SDK is the number one failure. Expo upgrades are routine when taken in order and a multi-week project when skipped, because each version carries native changes and every migration guide assumes you took the previous step. Two versions behind is an afternoon. Five is a rewrite with a different name. Schedule the upgrade rather than deciding on it, and do the client apps in sequence so the first one absorbs the surprises.

The tier boundaries must be mechanical. The line between src/ui/ and src/features/ is invisible in a diff and obvious in a lint rule. The no-restricted-imports config in references/structure.md is the whole review checklist above, minus the part where someone has to notice on a Friday. A rule that only lives in this document is true for the files written this month.

Deletability is the test the folder layout exists to pass. A feature should be a folder, a route file, and its query keys. Try it as a thought experiment on something real and list what you would have to touch: if the answer includes fragments in src/ui/, branches in unrelated screens, or keys in a shared store, the boundary leaked, and the list is your finding.

One consequence of generated code worth naming here: a model that cannot see your existing Field or formatCurrency writes a second one inside the feature it is working on. Nothing fails, and six months later there are four. When reviewing a new component or helper, search for what it duplicates before reading what it does.

See the maintainability skill for the general treatment.

No emoji as icons

Emoji are never icons. Not in buttons, tab bars, list rows, empty states, status badges, or headers. They render from the platform font, so they look different on iOS, on Android, and across OS versions; they cannot take a colour, a stroke weight, or a size that matches the rest of the set; a screen reader announces the Unicode name instead of the action; and they cannot be restyled when a client rebrands.

Use a real icon set behind one Icon atom with a typed name: @expo/vector-icons, lucide-react-native, or the client's own SVGs through react-native-svg. The same goes for copy: no emoji in labels, empty states, or push notifications.

No em-dashes

Never use an em-dash (U+2014, the long dash) anywhere: user-facing strings, comments, commit messages, documentation, or a README. It is the loudest tell that text was generated. A full stop, a colon, or a comma is almost always the better edit; a hyphen works when you genuinely need a break in a sentence.

This applies to every file in the project, not just the ones a user reads.

Sources

See references/sources.md for the reading list this was cross-checked against.