Typed routes make the URL honest. They do not make the network honest. Most
"state management" pain I see in React apps is server state wearing a
client-state costume, lists in Redux, invoice payloads in Context, refetch logic
hand-rolled in useEffect.
TanStack Query (long battle-tested; still the default I reach for in mid-2025 SPAs) is a cache with rules. The win is not the logo. The win is a boundary: what is remote and shared versus what is local and ephemeral.
Server state vs client state
| Kind | Examples | Owner |
|---|---|---|
| Server state | Invoice list, user profile, permissions, search results | Query (or framework loaders) |
| Client state | Modal open, wizard step, ephemeral form draft before submit | Component state / small local store |
| URL state | Active tab, filters you want shareable | Router search params (TanStack Router post) |
If a value is owned by the server and other screens might need the same bytes, it is server state. Putting it in a global client store guarantees staleness and duplicate fetches.
ts// src/queries/invoices.ts, illustrative keys + fetcher import { queryOptions } from '@tanstack/react-query'; export const invoiceKeys = { all: ['invoices'] as const, list: (filters: { q?: string }) => [...invoiceKeys.all, 'list', filters] as const, detail: (id: string) => [...invoiceKeys.all, 'detail', id] as const, }; async function fetchInvoice(id: string): Promise<Invoice> { const res = await fetch(`/api/invoices/${id}`); if (!res.ok) throw new Error(`Invoice ${id} failed: ${res.status}`); return res.json(); } export function invoiceQuery(id: string) { return queryOptions({ queryKey: invoiceKeys.detail(id), queryFn: () => fetchInvoice(id), staleTime: 30_000, }); }
Keys are API. Hierarchical keys let you invalidate a detail, a list, or the whole invoice domain without guessing string prefixes.
The lifecycle that matters
I teach four moments:
- Fetch:
queryFnis pure enough to retry; errors are typed as failures, notundefinedUI. - Cache:
staleTime/gcTimeencode how wrong you allow the UI to be. - Invalidate: after a mutation, say what became false.
- Reuse: another screen mounts the same key and gets cached data + background refresh, not a blank spinner by default.
tsx// features/invoices/InvoiceDetail.tsx, illustrative import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { invoiceKeys, invoiceQuery } from '@/queries/invoices'; export function InvoiceDetail({ id }: { id: string }) { const qc = useQueryClient(); const invoice = useQuery(invoiceQuery(id)); const save = useMutation({ mutationFn: (input: UpdateInvoice) => fetch(`/api/invoices/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }).then(async (res) => { if (!res.ok) throw new Error('Update failed'); return res.json() as Promise<Invoice>; }), onSuccess: (data) => { qc.setQueryData(invoiceKeys.detail(id), data); qc.invalidateQueries({ queryKey: invoiceKeys.all }); }, }); if (invoice.isPending) return <p>Loading…</p>; if (invoice.isError) return <p role="alert">{invoice.error.message}</p>; return ( <div> <h1>{invoice.data.number}</h1> <button type="button" disabled={save.isPending} onClick={() => save.mutate({ status: 'paid' })} > Mark paid </button> </div> ); }
Notice what is not here: a global invoicesSlice, a hand-rolled useEffect
fetch, or a silent swallow of non-OK responses.
Invalidation without guesswork
Rules I actually use:
- Prefer hierarchical keys so
invalidateQueries({ queryKey: invoiceKeys.all })means something. - After a successful mutation, update the detail you know (
setQueryData) and invalidate lists that might sort/filter differently. - Do not invalidate the entire app cache because one field changed, that is how you teach users to ignore loading states.
- Optimistic updates are optional. Correct invalidation is mandatory.
ts// Prefer explicit domain invalidation over qc.invalidateQueries() await qc.invalidateQueries({ queryKey: invoiceKeys.list({ q: undefined }) });
Framework loaders vs Query
Remix/RR loaders and Next server data are not enemies of Query. Boundaries:
- SSR/loader as source of first paint for a route, fine.
- Query for client navigations, shared caches, and polling: fine.
- Both fighting over the same resource with different keys: not fine.
If you already have a strong loader/action story and little client-side cache sharing, you may not need Query yet. Adopt it when multiple screens share remote data or when client transitions need stale-while-revalidate without reinventing it.
Failure modes
Everything in Context "for simplicity." You rebuilt a worse cache.
Keys that include unstable objects. Inline { ...filters } with new
identity every render → infinite fetch.
Ignoring error and empty states. data! assertions are how production lies.
Refetch-on-focus storms on endpoints that are expensive or rate-limited -
tune staleTime and refetchOnWindowFocus per query, not globally by panic.
Treating Query as a form store. Draft input is client state until submit.
Inventing metrics. I will not claim "X% fewer bugs" without measurement - the qualitative win is deleting effect-fetch spaghetti you can point at in review.
Continuity
The client vs server state boundary is the natural follow-on, then component-library and accessibility threads, the UI system story that sits on top of these contracts.
Takeaway
Server state is a cache with keys, staleness, and invalidation, not a global store you sync by hand. Draw the boundary, name the keys, invalidate on purpose; TanStack Query is one solid implementation of that discipline.