ADR-0008 — Containers, design components, primitives
| Date | 2026-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/**orcomponents/**may not import@/lib/db,@/lib/api,@/lib/sync,@/lib/identityor@tanstack/react-query - Anything in
src/app/**may not importStyleSheetfromreact-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.
ProfileScreentakes 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.
Related
- UI architecture — the working guide
- ADR-0003
- iOS native feel