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.
| State | Web | Mobile |
|---|---|---|
| Anything the server returned | A Server Component, as props | React Query |
| Applied filters, sorts, tabs, pages, dates and selected records | nuqs, through URL search params | Expo Router params |
| Defaults and durable preferences | Cookie or server-owned preference | Validated zustand store |
| Values one mounted subtree uses | useState | useState |
| Per-frame scroll, gesture, animation | — | Reanimated 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.
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:
| Read | Use | Caching |
|---|---|---|
| Public data | getPublicApi | revalidate, 300s by default |
| The signed-in reader's own data | getPrivateApi | never — no-store |
| An id list too long for a URL | getBatchedApi | as 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:
| Layer | Web | Expo |
|---|---|---|
| Filter meaning, normalization and request mapping | statshub-api-client | statshub-api-client |
| Route read/write | nuqs parser | Expo Router params |
| Saved starting values | server/user preference | validated zustand store |
| Fetched result | Server Component, or remaining SWR migration | React 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.
Chrome the reader arranged lives in a cookie
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:
export const UI_PREFERENCES = {
sidebar: booleanPreference("sidebar:state", false),
};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.