>_devkit
react-native-expo
skills/react-native-expo/

references/tiers.md

The component tiers

Atomic design gives you good vocabulary and bad boundaries. The vocabulary is worth keeping; the boundaries need replacing, because "is a search bar a molecule or an organism" has no answer when the differentiator is complexity.

This file replaces complexity with allowed knowledge. Every rule here is mechanical enough to be checked in review without a debate.


Tier 0: tokens

Not components. Plain values, in src/ui/tokens/.

Contains: colour ramps and semantic colours, the spacing scale, radii, type scale (size, weight, line height, letter spacing), elevation, motion durations and easings, z-index layers, hit-slop defaults, breakpoints.

Never contains: anything importable from React, any client-specific asset, any value used once.

The test: a token is a value at least two components need to agree on. A one-off marginTop: 13 is not a token, it is a bug in your spacing scale.


Tier 1: atoms

A single interface primitive. Lives in src/ui/atoms/.

The mechanical rule: an atom imports zero components from your own codebase. React Native primitives (View, Text, Pressable, TextInput, Image), tokens, and third-party primitives only. The instant it imports one of your atoms, it is a molecule. No judgement call, no meeting.

Can

  • Props, including a variant / size / tone API backed by tokens
  • Internal presentational state: pressed, focused, hovered, an animation

value, an uncontrolled fallback for a controlled prop

  • forwardRef, so parents can focus or measure it
  • Accessibility props, with sensible defaults per variant
  • Platform branches (Platform.select) for genuinely platform-shaped behaviour
  • Style overrides via a style prop, merged last
  • Its own tests and its own stories

Cannot

  • Import another component you wrote
  • Call useQuery, a Zustand store, useRouter, or useNavigation
  • Contain literal user-facing copy. Text arrives via props, always. An atom

with "Try again" baked in cannot be translated or reused.

  • Contain business validation. "Password must be 8 characters" is a schema

concern, not an input concern.

  • Contain outer margin. Spacing between things is the parent's job. An atom

with marginBottom: 16 is unusable in the one layout that needs 8.

  • Read from expo-secure-store, the filesystem, or the network
  • Know your API response shape

Examples

Text, Button, IconButton, Input, Checkbox, Radio, Switch, Avatar, Badge, Chip, Spinner, Skeleton, Divider, ProgressBar, Icon, Box / Stack layout primitives, Portal, Backdrop.

Note that Avatar is an atom even though it handles image loading, a fallback, initials, and three sizes. Complexity does not promote it. Importing your Text component to render the initials does.


Tier 2: molecules

Two or more atoms that only make sense together, doing one job. Lives in src/ui/molecules/.

The mechanical rule: the props contain no word from the client's business. If you cannot describe it without saying invoice, patient, listing, or booking, stop, it is an organism.

Can

  • Compose atoms and other molecules
  • Own the coordination state between its parts: open/closed, focused index,

which tab is active, whether the password is masked

  • Expose a controlled and an uncontrolled mode
  • Accept children and render-prop slots
  • Take callbacks (onSubmit, onSelect) and know nothing about what they do
  • Loop over a data array of a generic shape it defines itself

Cannot

  • Import from src/features/
  • Fetch, mutate, or subscribe to a store
  • Navigate. It calls onPress; the caller decides that means navigation.
  • Reference a domain type, even in TypeScript generics defaults
  • Read t('checkout.title'). Copy arrives as props. (i18n inside the design

system is acceptable only for genuinely universal strings like "Close", and even then a prop with that default is better.)

Examples

Field (label + input + hint + error), SearchBar, SegmentedControl, ListRow (leading slot, title, subtitle, trailing slot), CardShell, Accordion, Stepper, Pagination, EmptyState, Toast, BottomSheetShell, DatePickerField, Tabs, Rating.

EmptyState is a molecule: illustration + title + body + optional action, all via props. NoInvoicesYet wrapping it with copy and a "Create invoice" button is an organism in the invoices feature.


Tier 3: organisms

The first tier that speaks the domain. Lives in src/features/<feature>/components/, not in src/ui/.

This is the most important deviation from textbook atomic design, and it is what makes the whole thing survive contact with a real product. An organism knows what an Invoice is. That knowledge is exactly what makes it useless on the next project, so it must not sit in the folder you copy between projects.

Can

  • Import atoms, molecules, and templates from src/ui/
  • Import and render other organisms from the same feature
  • Use domain types in its props: <InvoiceRow invoice={invoice} />
  • Call the feature's data hooks (useInvoices()) and its store
  • Decide loading, empty, and error presentation for its own slice
  • Trigger navigation, though passing an onPress up to the screen is usually

better and always more testable

  • Read i18n keys directly

Cannot

  • Live in src/ui/
  • Import another feature's internals. Cross-feature needs go through a public

surface the other feature explicitly exports, or through lib/.

  • Define layout chrome that belongs to the screen (headers, safe areas, tab

bars). That is the template's job.

  • Be the thing the route imports. The screen is.

Examples

LoginForm, InvoiceList, InvoiceRow, CartSummary, ProductGallery, AppHeader (because it renders the signed-in user), FilterSheet, PaymentMethodPicker, OnboardingCarousel.

AppHeader is worth dwelling on. A header that takes title, onBack, and a right slot is a molecule and belongs in src/ui/. A header that reaches into the auth store for the avatar and shows an unread badge from a query is an organism and belongs to a feature. Same pixels, different tier, because the knowledge differs. When in doubt, build the dumb one in ui/ and let a thin organism wire it up.


Tier 4: templates

Layout with holes in it. Lives in src/ui/templates/.

A template positions slots and owns page chrome: safe areas, scroll behaviour, keyboard avoidance, header and footer placement, max width on tablets, the loading and error shells. It never knows what goes in the holes.

Can

  • Accept ReactNode slots: header, children, footer, floatingAction
  • Own SafeAreaView, KeyboardAvoidingView, ScrollView vs FlashList choice
  • Own the screen-level padding rhythm, which is why atoms do not carry margin
  • Provide isLoading / error props and render skeleton or error shells
  • Handle responsive layout at breakpoints

Cannot

  • Fetch anything, hold domain state, or import from features/
  • Name a slot after the domain (invoiceHeader is wrong, header is right)

Examples

ScreenScaffold, ListScreenTemplate, FormScreenTemplate, DetailScreenTemplate, ModalScreenTemplate, AuthScreenTemplate.

Be honest about whether you need them

Templates are the tier teams cargo-cult and then resent. Create one when three or more screens share the same chrome, not before. Two screens is a copy-paste you can afford; the abstraction costs more than the duplication until the third caller shows up, and it costs a lot more if you guess the slots wrong.

If your app has one chrome, you need exactly one ScreenScaffold and you are done. That is a success, not a shortfall.


Tier 5: screens

Textbook atomic design calls these pages. In an Expo app they are screens, and they live in src/features/<feature>/XScreen.tsx.

A screen is the orchestrator and the only tier allowed to be messy about concerns, because it is the only tier that is genuinely one-of-a-kind.

Can and should

  • Read route params (useLocalSearchParams) and validate them with zod
  • Call data hooks and pass results down
  • Own screen-level side effects: analytics on mount, focus refetch, deep-link

handling, permission prompts

  • Compose a template with organisms
  • Handle navigation

Cannot

  • Contain styling beyond passing props. If a screen has a StyleSheet.create

with more than a couple of entries, layout leaked out of the template or a component is missing.

  • Be imported by anything except its route file and its own tests

The route file

// app/(app)/invoices/[id].tsx
export { InvoiceDetailScreen as default } from '@/features/invoices/InvoiceDetailScreen';

That is the entire file, plus Stack.Screen options and unstable_settings where needed. Layout files (_layout.tsx) are the exception: navigator configuration is genuinely routing, so it belongs in app/.


Where does X go

ThingTierHome
Button, Input, Avatar, Badge, Spinneratomui/atoms/
Icon set wrapperatomui/atoms/
Label + input + errormoleculeui/molecules/
Search bar, segmented control, tabsmoleculeui/molecules/
Generic list row with slotsmoleculeui/molecules/
Card shell (no content)moleculeui/molecules/
Bottom sheet chromemoleculeui/molecules/
Bottom sheet contentsorganismfeatures/<f>/components/
Login formorganismfeatures/auth/components/
Product card, invoice roworganismfeatures/<f>/components/
Header showing the current userorganismfeatures/<f>/components/
Screen scaffold, safe area, keyboard avoidancetemplateui/templates/
The screen itselfscreenfeatures/<f>/
Route filenoneapp/
Toast componentmoleculeui/molecules/
Toast provider and queueinfrastructurelib/toast/
Date formatting, currency formattingnot a componentlib/format/
Permission checks, feature flagsnot a componentlib/
API client, interceptorsnot a componentlib/api/
Analytics wrappernot a componentlib/analytics/
Error boundaryinfrastructurelib/errors/
Theme providerinfrastructureui/theme/

Promotion and demotion

Components are not born shared. They are promoted, and the promotion has a price of admission.

Start every component inside the feature that needs it. Even a button. It costs nothing to move later and it costs a great deal to design a shared API for a single caller.

Promote on the third caller, not the second. Two callers is a coincidence; three is a pattern. Promotion is not a move, it is a rewrite:

  1. Strip every domain prop. invoice becomes title, amount, status.
  2. Strip every i18n key. Copy becomes props.
  3. Strip navigation. Actions become callbacks.
  4. Strip outer margin.
  5. Add the variants the three callers actually need, and no more.
  6. Write its tests against props alone, with no providers mounted.

If step 1 is impossible, it was never shared UI. Leave it in the feature and let the other two callers import it from there, or extract the shared hook instead of the shared component.

Demote on the first if. The moment a component in src/ui/ grows

{variant === 'checkout' && <TaxDisclaimer />}

it has learned the business and must move back into the feature. A shared component with a client-specific branch is worse than two separate components, because the branch will be copied to the next client and nobody will remove it.


Misfilings to watch for in review

Shared organisms. src/ui/organisms/OrderSummary.tsx. This is the big one. Ask: could this file compile in a project with a different business? If not, it belongs to a feature.

Atom soup. Twenty atoms that wrap one RN primitive with one prop change. PrimaryButton, SecondaryButton, DangerButton, SmallButton. That is one Button with a variant and a size.

Molecules that fetch. A UserPicker in ui/ that calls useUsers(). Split it: the picker takes options and onSelect; the feature supplies the data.

Templates with data. A DashboardTemplate that knows there are three metric cards. That is an organism named wrong.

Screens with StyleSheets. Fifty lines of layout in a screen means the template is missing or too rigid.

Feature-to-feature imports. features/checkout importing features/catalogue/components/ProductRow. Either the row is genuinely shared, in which case promote it to ui/ after stripping the domain, or checkout needs its own, or catalogue should expose a deliberate public entry point. Enforce this with an ESLint boundary rule rather than vigilance.

The 1:1 route mirror. features/ folders that exactly mirror app/. That means features are being organised by screen rather than by domain, and shared domain logic will have nowhere to live. Features are nouns of the business, not of the navigation.