StatsHub Docs

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:layers

It 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 data

features 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 domainteam-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 pages

Grouped 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.

On this page