Skip to main content

ADR-0008 — Containers, design components, primitives

Date2026-08-17
Supersedes
Superseded by

Context

The first pass at the app put data fetching, layout and navigation in the same file. A screen was a route file with useQuery at the top and StyleSheet at the bottom.

That is fine until you want to look at one. Reviewing the empty state meant emptying the database; reviewing the offline state meant turning off wifi; reviewing "synced 4 minutes ago" meant waiting four minutes. So those states did not get reviewed, and they are exactly the states an offline-first app spends its day in.

Decision

Three layers, with the boundaries enforced by ESLint rather than by good intentions.

src/app/**— containers. Route files. They call ause-\*.ts hook, wire the result into a screen component, and handle navigation. No layout, no styles.

src/features/<area>/<name>-screen.tsx — design components. The whole screen, pure. Every value and every callback arrives as a prop. No data access, no react-query, no clock.

src/features/<area>/*.tsx — composed components. CragCard, RouteRow. Domain-aware, still pure.

src/components/**— primitives.GlassSurface, GradeBadge, ListRow, Tag. No domain knowledge; reusable anywhere.

src/features/<area>/use-*.ts — data. The only layer that touches SQLite or triggers sync.

Enforced, not encouraged

apps/mobile/eslint.config.js restricts imports:

  • Anything in features/** or components/** may not import @/lib/db, @/lib/api, @/lib/sync, @/lib/identity or @tanstack/react-query
  • Anything in src/app/** may not import StyleSheet from react-native

Both fail CI. A convention nobody can violate by accident is worth more than a convention written down in a document.

The clock counts as data

ProfileScreen takes lastSyncedLabel: string, not a timestamp. Reading Date.now() during render is impure — React's own lint rule flags it — and it makes "4 min ago" untestable. The hook captures the clock in state and formats; the screen displays.

Consequences

What this buys:

  • Every screen state is a story. Loading, empty, offline-with-pending-work, not-downloaded — all plain objects in *.stories.tsx, reviewable in a browser with no database.
  • Screens are testable without mocking a database or react-query, though there are still no mobile tests.
  • Reuse is visible. Something that belongs in components/ is obvious, because it is the thing two features both import.

What it costs:

  • More files. A screen is now three or four instead of one. Genuinely worse for a trivial screen; the ESLint rule applies anyway, so there is no escape hatch for "this one is small".
  • Prop drilling. A screen with many states has many props. ProfileScreen takes seven. If that reaches the point of pain, the answer is a view-model object per screen, not context.
  • An indirection to trace. Reading "what happens when I tap a crag" means route file → screen → card, rather than one file.

Alternatives considered

Keep data fetching in the screen, mock the hooks in stories

Fewer files, and Storybook can mock a hook with a decorator. Rejected because the mocking has to be maintained: every new query is a new mock, and a story that forgets one renders against a real database or silently fails. Passing props is the mock, and it cannot drift.

A single useScreenState hook per screen returning a view model

Close to what we do, but keeps the hook inside the screen component. Rejected for the same reason: the screen is then not renderable without whatever the hook touches.

Colocate everything by screen, no shared components/

Rejected on reuse. GlassSurface, GradeBadge and ListRow are already used by three features each, and a per-screen copy is how a design system quietly becomes five design systems.

Open questions

  • Where does a route detail sheet live when it is opened from two places? Probably features/routes/, with both callers passing props — but it is untested.
  • Prop count. No rule yet on when a screen's props become a view-model object.
  • No mobile tests exist, so the testability this buys is theoretical until someone writes some.