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:
- Nobody can say where a new file goes. Every developer invents a folder,
so the same concept lives in four places.
- UI components learn the business. A
Cardgrows aif (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.
| Concern | Choice |
|---|---|
| Framework | Expo SDK 55 (React Native 0.83, React 19.2), New Architecture only |
| Routing | Expo Router v7, typed routes on |
| Language | TypeScript, strict: true, no any in reviewed code |
| Server state | TanStack Query |
| Client state | Zustand, one store per feature |
| Forms | react-hook-form + zod, one schema shared with the API types |
| Lists | FlashList v2 |
| Animation | Reanimated (worklets) + Gesture Handler |
| Storage | MMKV for state and cache, expo-secure-store for secrets |
| Styling | Unistyles, or NativeWind when the team also ships web Tailwind |
| Testing | Jest + React Native Testing Library, Maestro for journeys |
| Errors | Sentry, 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.
| Tier | May import | May know | Never |
|---|---|---|---|
| tokens | nothing | raw values | any component |
| atoms | tokens, RN primitives | its own props | any other component of yours |
| molecules | tokens, atoms | its own props | domain nouns, stores, queries, navigation |
| organisms | anything above, feature hooks | one domain concept | another feature's internals |
| templates | tokens, atoms, molecules | layout and slots | data, domain nouns |
| screens | anything | routing, orchestration | being 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:
| Kind | Home | Note |
|---|---|---|
| Design tokens | src/ui/tokens/ | colour, spacing, radius, type scale, motion |
| Runtime config | src/config/env.ts | parsed with zod at boot, crashes loudly if wrong |
| Storage keys | src/lib/storage-keys.ts | one file, global, because collisions are silent |
| Query keys | src/features/<f>/queryKeys.ts | a factory per feature, never string literals |
| Domain enums | with the feature that owns them | promote 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-storeinstead. - 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:
| Kind | Example | Home |
|---|---|---|
| UI behaviour | useDisclosure, useDebouncedValue | src/hooks/ |
| Platform | useAppState, useKeyboardHeight | src/hooks/ |
| Data | useInvoices, useCreateInvoice | features/<f>/api.ts |
| Feature logic | useCheckoutFlow | features/<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:
- FlashList v2 for any list longer than a screen. v2 dropped
estimatedItemSize and friends, so remove them on migration. Give getItemType for heterogeneous rows.
- Animations in Reanimated worklets, never in
setStateon a scroll or
gesture handler.
- 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.
expo-image, notImage, for anything remote or cached.- 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 fromsrc/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.tsbarrel - An interactive element without
accessibilityLabelandaccessibilityRole - An emoji used as an icon
- An em-dash anywhere
-
any, or a@ts-expect-errorwithout 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.