StatsHub Docs
Conventions

Where state lives

On web the server fetches server state and passes it as props; the URL owns the rest, through nuqs. On mobile React Query owns server state and a zustand store owns the rest, because there is no URL to put it in.

Four owners, and the boundary between them is the platform. Pick by asking who should be able to reproduce the state, not by which library is nearest.

StateWebMobile
Anything the server returnedA Server Component, as propsReact Query
Anything a user would expect to survive a link, a refresh or a back buttonnuqs — it lives in the URLzustand
Chrome the reader arranged — sidebar collapsed, panel pinnedA cookie, so the server can render itzustand
Values one mounted subtree usesuseStateuseState
Per-frame scroll, gesture, animationReanimated shared values

On web, server state is fetched on the server

A board's data is read in the page.tsx that renders it and handed down as props. The client component keeps the address bar and the table; it does not fetch.

app/(dashboard)/(features)/(stats)/referees/page.tsx
export default async function Page({ searchParams }) {
  const params = await searchParams;
  const board = await getPublicApi<{ data: RefereeData[] }>(
    "/api/referees/list",
    { query: { sortField: params.sort ?? "avg_cards" } }
  );
  return <View referees={board?.data ?? []} />;
}

The filters are already in the URL, so the server has everything it needs to build the same request the browser used to. What that buys is not a benchmark number: the first paint carries rows instead of a skeleton — for a crawler too — and the request leaves from inside the network the API is on rather than from the reader's connection, which for a board that chained three dependent reads was three round trips across the public internet before a single row appeared.

Three clients, and the difference between them matters:

ReadUseCaching
Public datagetPublicApirevalidate, 300s by default
The signed-in reader's own datagetPrivateApineverno-store
An id list too long for a URLgetBatchedApias given; batches go out together

getPrivateApi forwards the request's cookies, because the Go API's auth.AccessToken accepts the @supabase/ssr session cookie. It is no-store rather than a short revalidate on purpose: a cached response there is one reader's watchlist served to the next one.

nuqs is client-only

A state.ts that builds nuqs parsers cannot be imported by a Server Component — the build fails with "Attempted to call parseAsArrayOf() from the server". Constants both the page and the parsers need go in a defaults.ts that neither pulls the other into. This is the first thing that breaks when converting a board.

Live data does not need a second data layer

Scores and lineup confirmations change during a match. The server seeds them and the view runs router.refresh() on an interval; the server re-renders and streams new HTML. The client holds a timer, not a copy of the fetching logic.

"Today" is not the server's day

A 21:00 match in Auckland is a different date in UTC. In the browser the day was free — new Date(). On the server the container is UTC and would serve New Zealand readers yesterday's fixtures every evening, so the reader's IANA zone travels in a statshub_tz cookie (lib/server-time.ts, written by TimeZoneSync) and falls back to UTC until it lands.

SWR is what is left, not what to reach for

apps/statshub-web still has 142 useSWR calls across 46 files, and they are being retired board by board. Do not add one to a page-level view. React Query is not a dependency of this app at all and never was — the cache here has always been SWR.

The remaining legitimate case is a read triggered by an interaction rather than by the address: a sheet that opens when a row is clicked. Fetching those on the server means fetching for every row on the board whether or not anyone opens one. Where the sheet IS driven by the URL — ?player=123 — it belongs on the server like everything else.

Every remaining SWR call goes through lib/fetcher.ts. Do not write a fetcher inline — there were 38 of them, and all 32 of the copy-pasted ones shared a bug:

// Wrong, and was everywhere. `fetch` resolves on a 500, so `res.json()`
// parses the error body, SWR reports no error, and the board renders its
// empty state for a broken API.
const fetcher = (url: string) => fetch(url).then((res) => res.json());

// Right.
import { fetcher } from "@/lib/fetcher";

Never copy a fetched result into a store or into useState. A copy is a second cache with no invalidation, and it goes stale the moment the source changes.

On web, the URL is the store

nuqs keeps state in the query string with a useState-shaped API, so a filter, a sort, a tab or a page number is a real part of the address.

import { useQueryState, parseAsInteger } from "nuqs";

const [market, setMarket] = useQueryState("market");
const [page, setPage] = useQueryState("page", parseAsInteger.withDefault(1));

The reason is not tidiness. A screener with its filters in React state produces one URL for every possible view of the data, which means a user cannot send someone what they are looking at, cannot bookmark it, cannot reload without starting over, and the back button does nothing they expect. Putting that state in the URL fixes all four at once, and it is the same amount of code.

So on web the question is not "store or component state" — it is "does this belong in the URL", and for anything a user chose, it usually does.

A param that the server reads is shallow: false

Now that boards are fetched on the server, this is the rule that decides whether anything happens at all. nuqs writes the URL shallowly by default — the address changes and the server is never asked again — so a filter the server reads must opt out, or the board freezes on its first render and looks broken in a way that produces no error anywhere.

const [isLoading, startTransition] = useTransition();
const serverQueryOptions = { ...pageQueryOptions, shallow: false, startTransition };

// Read by the server: re-runs the page.
const [sort] = useQueryState("sort", parseAsString.withOptions(serverQueryOptions));

// Applied in the browser to rows already in hand: stays shallow, stays instant.
const [page] = useQueryState("page", parseAsInteger.withOptions(pageQueryOptions));

Passing startTransition is what gives the board its loading state: pending covers exactly the window between writing a filter and its rows arriving, and React keeps the current rows on screen meanwhile. That is the isLoading an SWR hook used to hand over.

A search box is the one that bites. The delay belongs on the write, because the write is the request:

parseAsString.withDefault("").withOptions({
  ...serverQueryOptions,
  limitUrlUpdates: { method: "debounce", timeMs: 500 },
})

Debouncing a value derived from the URL instead — which is what these boards did when the request was built in the browser — now puts every keystroke in the address bar and in the back button's history.

When a board has more than a handful of filters, the URL→API mapping moves into a query.ts beside the view so the page and the parsers cannot drift. value-bets maps eighteen params that way, outliers twelve.

Reach past nuqs only when the state genuinely should not be shareable: an open menu, a draft input, a dismissed banner. Those are useState.

In use

nuqs@2 is a dependency of apps/statshub-web, NuqsAdapter wraps the app in src/app/(dashboard)/layout.tsx, and there are 29 files using useQueryState today — every screener, every trends board, every value-bets view. This describes what is there, not only what comes next. The (legacy) route group has no adapter; new work does not belong there anyway.

On mobile, zustand holds what the URL would have

The Expo app has no address bar, so the state a web page would put in the query string has to live somewhere: device preferences, saved filters, favourites, durable layout, sheet bridges, shell state. That is zustand's job, across 49 files today.

Stores use subscribeWithSelector, components select the smallest value they render, and shallow equality is for when several fields must travel together. Persisted stores validate what they read off disk and keep their storage keys stable — see the mobile architecture page and the zustand skill.

zustand is not the web default

The zustand skill describes a portable core meant to be shared between web and native. That predates this rule. On web, prefer nuqs; a shared store is worth it only when the same non-URL state genuinely exists on both platforms, which so far it does not. zustand is in apps/statshub-web's package.json and has zero call sites in src/ — that is the intended state, not an oversight. Reaching for it to hold a filter is how a screener stops being linkable.

The sidebar being collapsed is not a filter — nobody wants it in a link they send — but it does have to survive a reload, and it has to be right in the first paint. That second requirement is what rules out localStorage: the server cannot read it, so the HTML goes out with the rail expanded and hydration snaps it shut a frame later. A cookie arrives with the request.

Declare one in lib/ui-preferences.ts and both ends follow:

apps/statshub-web/src/lib/ui-preferences.ts
export const UI_PREFERENCES = {
  sidebar: booleanPreference("sidebar:state", false),
};
Any client component
const [open, setOpen] = useUiPreference("sidebar");

setOpen writes the cookie with document.cookie — not a Server Function, because nothing on the server needs to know until the next full load, and a round trip would make the rail wait on the network to move. The dashboard layout reads whatever is declared, once per full load:

app/(dashboard)/layout.tsx
const uiPreferences = await readUiPreferences();

Two things to know before adding one. cookies() is a request-time API, so reading it opts a route into dynamic rendering — that layout already awaits it for the locale, and a route you want prerendered should not gain one. And every cookie is sent on every request to the origin, so this is for the handful of things that must be on screen correctly before JavaScript runs, not a general key-value store.

Context is scope, not storage

A provider is for real tree scope or dependency injection. A global singleton is not made clearer by wrapping it in one.

On this page