StatsHub Docs

Where state lives

Server caches own fetched data, routes own reproducible choices, and device stores own preferences. The adapter differs by platform; the ownership rule does not.

Four owners, chosen by what the state represents. Pick by asking who should be able to reproduce it, not by which library is nearest.

StateWebMobile
Anything the server returnedA Server Component, as propsReact Query
Applied filters, sorts, tabs, pages, dates and selected recordsnuqs, through URL search paramsExpo Router params
Defaults and durable preferencesCookie or server-owned preferenceValidated zustand store
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/web-app-router 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, committed screen inputs are URL state by convention. This includes filters, sorting, tabs, pagination, date ranges, calculator inputs and the record currently selected from a board. An open menu, hover target, unsubmitted form field or dialog remains local because reproducing it from a link would not reproduce useful application state.

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/web-app-router, and NuqsAdapter wraps both the primary app layout and the experimental UI layout. Page components use the shared parsers in src/lib/query-state.ts and feature-specific state modules.

On mobile, route params and preferences are different owners

Expo has no visible address bar, but Expo Router still gives every screen a URL. A committed screen choice belongs in route params, just as it does on web. Device defaults, favourites, durable layout, sheet bridges and shell state are not part of a shared link; those remain in zustand. A filter screen may keep a draft locally while it is being edited, but Apply writes the complete normalized selection to the board route.

Do not share a nuqs hook with native. Share the filter model and codec, then put a small adapter on either side:

LayerWebExpo
Filter meaning, normalization and request mappingstatshub-api-clientstatshub-api-client
Route read/writenuqs parserExpo Router params
Saved starting valuesserver/user preferencevalidated zustand store
Fetched resultServer Component, or remaining SWR migrationReact Query

The route carries the complete normalized platform projection once a reader changes it. A recipient's personal defaults therefore cannot change supported choices in a shared link. With no route state, native may still seed a new screen from the reader's saved defaults. A platform must discard a shared field it cannot both display and execute; installing an invisible filter is worse than an explicit capability boundary.

Team Screener is the concrete example. Its two adapters use the same filter model, normalization, codec and request mapping from statshub-api-client, but nuqs and Expo Router stay in their own apps.

Request identity comes from the request mapper

A cache key or automatic-refetch signature must include every field that changes the request, and exclude fields that do not. Hand-maintained dependency lists drift: a new odds range starts changing the URL but reuses an old cached page, or a bookmaker display toggle causes another server request even though the server cannot filter bookmakers.

Derive request parameters and request identity from the same normalized model. Likewise, do not delay the board request on metadata used only to populate a filter menu. Tournament and fixture options may keep loading while the board's own independent request starts.

Zustand 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. The primary web app therefore has no Zustand dependency. 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/web-app-router/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.

cookies() is request-time data. With Cache Components and PPR enabled, readUiPreferences() must run in a request-time child below Suspense, not at the top of a layout or page. That keeps the shared shell cacheable while the reader's chrome fills in separately. Cookies are also sent on every request to the origin, so use them only for the few choices that must be correct on first paint, not as 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