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.
| State | Web | Mobile |
|---|---|---|
| Anything the server returned | A Server Component, as props | React Query |
| Anything a user would expect to survive a link, a refresh or a back button | nuqs — it lives in the URL | zustand |
| Chrome the reader arranged — sidebar collapsed, panel pinned | A cookie, so the server can render it | zustand |
| 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/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.
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. The dashboard layout
reads whatever is declared, once per full load:
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.
Route layout ownership
Which App Router file owns navigation, headers, filters, search rails, and other persistent route chrome.
How apps find each other
One app's address is never written down in another. `appUrl("web", "/prop-screener")` reads the port from that app's own dev script, and an environment variable moves it.