Architecture
The route/composed/block split every screen follows, who owns which kind of state, and where files live.
The three layers
Every screen in the app is built out of three files, each with one job. This is the single most important thing to understand about the codebase.
A route mounts a screen. It holds no UI and no data. In practice it is four lines: import the composed screen, render it.
A composed screen fetches. It calls the query hooks, reads the router params, holds any state the screen needs, and hands plain values down. It renders one thing: its block.
A block draws. It takes props and returns UI. It does not fetch, does not read the router, does not reach into a store. Given the same props it renders the same pixels, which is what makes it previewable in isolation.
The reason for the split is that these three things change for different reasons and at different rates. A designer changing a card touches features. An API change touches composed screens. A navigation change touches routes. Keeping them apart means a change to one does not force you to read the other two.
This is enforced by a script rather than by trust:
bun run check:layersIt exits non-zero today. The files it lists are a backlog inherited from the port out of the old monorepo. That list may shrink and may never grow. If you add a violation, the script fails and you fix it; do not add an exception.
State ownership
Choose state by its update rate and owner, not by trying to put every value in one library.
- TanStack Query owns server responses, request status, caching and invalidation. Do not copy query data into a Zustand store.
- Zustand owns shared client state: device preferences, durable layouts,
favourites, saved picks, purchase snapshots, sheet bridges and shell state.
Stores use
subscribeWithSelector; components select the smallest value they render and use shallow equality only when several fields must travel together. - Component state owns temporary values used by one mounted subtree, such as an open menu, draft input or selected local segment.
- Reanimated shared values own per-frame scroll, gesture and animation state. Do not send offsets through Zustand or React state. Schedule a JS callback only for semantic transitions such as a gesture commit.
- Context is for real tree scope or dependency injection. A global singleton is not made clearer by wrapping it in a provider.
Persisted stores validate the value read from disk and keep their existing storage keys stable. Because native storage hydrates asynchronously, a user edit made before hydration finishes must win over the older disk value.
Where things live
app/src/app/ routes (grouped with `()` — see below)
app/src/composed/ one file per screen: fetches, feeds a feature
app/src/components/features/ one folder per screen: the UI a composed screen feeds
app/src/components/filters/ the filter panel each feature mounts
app/src/components/content/ cards, domain views, skeletons, decoration
app/src/components/layout/ shells, chrome, toolbars
app/src/components/overlay/ sheets and menus that present over a screen
app/src/components/reusable/ the generic pieces: rows, primitives, atoms
app/src/lib/ data layer, app state, static datafeatures are screens. They were src/blocks/ at the top level, on the
argument that a three-layer rule reads better as three sibling directories.
They moved under components/ to match the web app, where the same split is
composed/<feature> for the state and components/features/<feature> for the
markup — "a feature renders, a composed owns", in both apps, with the same
folder names. Two codebases with one vocabulary was worth more than the tree
spelling the layers out.
The layer rule itself did not change, and bun run check:layers still enforces
it: app/ → composed/ → components/features/. A composed screen may import
its own feature and the filter panel that feature mounts, and nothing else that
renders.
components/ is then everything the app draws, sorted by KIND rather than by
screen. The folders are the web app's, and the distinction matters when you are
deciding where to put something new.
layouts are full-screen shells that more than one block shares. A board with a virtualised list and its loading and error states. A native grouped list. A modal picker. If two features are drawing the same frame around different content, that frame belongs here.
chrome is the app's bars: the stack header and the options that configure
it, the scroll-edge blur band it fades in, the sheet header, the sheet's surface
and hero, the persistent-sheet shell. These are the things that are the same on
every screen and are how you tell you are still in this app. They were spread
between core and, in the sheet header's case, a file about the screener's
league picker — six screens imported a header filed under one of them, which is
a header the other five cannot find.
core is what is left: the shared pieces smaller than a screen that are not bars. Gradients, skeletons, error boundaries, the detail card, the filter bar and panel.
primitives are the SwiftUI-backed building blocks: list rows, grouped lists, pickers. See below — they were an npm package until they moved in here.
cards is every ITEM a list draws. If a component is what renderItem
returns, or is mapped once per record, it is a card and it lives here —
ScreenerRow, PropRow, TrendRow, RefereeRow, StandingsRow, the two
fixture rows, the search and browse rows, Home's section card and the feed rows
inside it.
They were scattered: six were private functions at the top of the block that
listed them, two were closures inside a screen, and the rest were filed by
subject under domain. Written in the block, a row looks like part of that
screen — which is how five boards ended up with five different answers to the
same question about padding a crest. In one folder they are visibly one family,
and the next one you write starts by reading its neighbours.
The name is the claim: they are all cards, whatever the component is called. A
row on a board and a card on Home differ in radius and gap, not in kind, and
both take their metrics from cards/list-card.
Generic small pieces — tabs, pills, pagers, the tray, Text, Button — sit
LOOSE at the root of components/, in no folder at all. They were under
reusables/, which was a name for "component", so every one of them qualified
and the folder sorted nothing; what it did do was hide them one level down from
the person deciding whether one already existed. A flat root is the honest
shape: the folders below name a real kind, and anything that does not have one
is simply a component.
The rule of thumb: if it owns the whole screen it is a layout, if it is a whole screen it is a block, if it is a bar it is chrome, if it is a SwiftUI building block it is a primitive — otherwise it is core, or it is loose at the root.
components/domain is the one folder grouped by SUBJECT rather than by kind —
fixture, player, screener, search, team. Everything else under
components is generic: a PageHeader or a Skeleton would work in a recipe
app, and a FixtureHero would not.
So the test for where something goes is: does it know what this app is about?
If it does, it belongs under domain. If it does not, it is chrome, core, a
primitive, a layout, or loose at the root.
Once you have picked the folder, conventions.md covers what the file itself looks like — the doc comment and how props are typed.
That test is about the component, NOT about how many files import it. The two
get confused, and the confusion has a direction: something used from every
screen starts to feel general, and gets filed as though it were. TeamAvatar is
imported by twenty-nine files and builds StatsHub's own image URLs — widely
used, and unusable anywhere else. It belongs to domain on the second fact and
the first one does not get a vote.
Inside domain, a component that belongs to ONE subject goes in that subject's
folder; one that spans several sits at the root of domain — team-avatar
(crests and faces, called from everywhere), player-filter-kit (the fixture and
team screens both), detail-rows (a club row and a player row). Picking a
subject folder for those would make them a stranger in every other one.
This was called _legacy until it stopped being true. It began as a holding pen
for the port out of the old monorepo, and while everything in it was unsorted
that name was honest. It is now five feature folders of live code that renders
on screen, and naming a directory after its age tells you nothing about what to
put in it.
lib has no such folder. Everything that was in lib/_legacy is filed under
lib/api, lib/core or lib/static by what it does: the fixture models,
helpers and detail fetchers went to lib/api/fixtures, entity detail to
lib/api/players and lib/api/teams, the screener controller to
lib/api/screener, the sheet bridges and the favourites store to lib/core,
and the curated team and player lists to lib/static.
The app talks to two backends, and they are not the same kind of thing. The
StatsHub API is READ-ONLY and someone else's, reached through the clients in
lib/api/http. Convex is ours and holds Better Auth plus the account profile;
lib/core/backend-provider is the single client seam for both.
Within the StatsHub API there are then TWO transports.
lib/api/statshub/client is the richer one — it parses, unwraps envelopes,
backs off on Cloudflare's 403/429/503 — and is what new code should use.
lib/api/fetch hands back a raw Response and exists because the older
hand-rolled fetchers are written against it. Moving those over means changing
what each returns, and through them the shapes their screens read; it has not
been done.
primitives and ui
Both were separate npm packages carried over from the monorepo this app was
ported out of, and inlined as ordinary source without being rearranged. That is the whole
reason they sat at the top level next to app/ and lib/: the shape was the
package's, not this app's.
components/primitives/ is the former native package, moved in. Grouped
lists, list rows, pickers, an empty state — components backed by real SwiftUI
hosts rather than by React Native views, so they look and behave exactly like
the system's because they are the system's. Eight files, all of them used;
nothing was left behind. Import from @/components/primitives/list-row.
ui/ is still where it was, and it is the odd one. The app reaches into it
for exactly three things: Text (72 files), Button (3) and the provider (1).
Behind those three is a 11,000-line design system — a toast host, a bottom
sheet, portals, slots, a colour-manipulation kit — and every line of it is
reachable, because the barrels in helpers/internal and helpers/external
export * over their whole directory. Text is a 63-line wrapper over React
Native's Text and it pulls in 10,850 lines through those barrels.
Nothing is broken by that; Metro bundles it either way. But it is worth knowing
before anyone treats ui/ as a design system this app maintains, because it
isn't one — it is a vendored library with a three-symbol surface.
Everything under components and the @statshub/ui-native kit counts as UI to the layer
checker. A composed screen importing from any of them is the same violation as
importing a block's internals.
The route groups
app/src/app/ is organised with () groups. expo-router treats those as
invisible to URLs, so (tools)/trends.tsx is still /trends. They exist so the
directory says what a screen IS: before them it was thirty-five files in a row
where a board, the sheet that filters it and a privacy page were
indistinguishable.
app/src/app/
index.tsx the launch gate: intro or tabs
search.tsx reachable from anywhere, so it belongs to no group
calendar.tsx
(tabs)/ StatsHub + Search
(onboarding)/ the intro and its panel
(tools)/ every destination on the Tools hub, screener/ included
(detail)/ fixture/, player/, team/, league/, expanded/
(filters)/ the five advanced-filter sheets
(settings)/ profile, more, home-customise, convert, the info pagesGrouped by ROLE, not by presentation. How a screen appears — push, modal, sheet
— stays in lib/core/route-presentation.ts, because that is a thing you change
your mind about and a file move is a poor way to express it.
Two names per route
The (tabs) group has its own layout. The other groups are organisational: their children
are hoisted into the root Stack. But hoisting does not drop the segment — the
group stays in the screen name. A file at (tools)/trends.tsx is declared as
<Stack.Screen name="(tools)/trends">, and <Stack.Screen name="trends">
matches nothing at all.
So a route has two names, and both are derived in route-presentation.ts:
stackNameFromContextKey gives (tools)/screener/index — what the navigator
knows it as, and what the presentation tables are keyed by.
routeNameFromContextKey gives screener — the screen's stable identity, with
groups and a trailing index stripped.
Adding a presented route means putting the file in a group AND adding its full
stack name to the table in route-presentation.ts.
How to add a screen
Work outside in.
Start with the block. Define the product states and their fallbacks at that seam.
Then the composed screen. Wire the real queries, pass the real values down.
Then the route. Mount the composed screen. Done.
If partway through you find the block needs to know something only the router has, that is a signal you have drawn the seam in the wrong place. Pass it as a prop.