How server data moves: one fetch seam, generated query keys, mutations that toast and invalidate, and reads that suspend inside a boundary.
Cross-cutting laws·docs/data.md·91 lines
Kit src/lib/api.tsLiving example · Data Pipeline
How server data moves through the app. A cross-cutting law like feedback.md — it applies inside every page class. The kit is src/lib/ (api.ts, query-keys.ts, use-app-mutation.ts); the living example is /labs/foundations/data, running against a real HTTP round-trip (/api/demo/notes).
01
TanStack Query is the engine, not the data layer. The engine was pinned long ago (AGENTS.md: server state = react-query, never zustand). What drifts is everything the engine doesn't decide: what fetches, how keys are shaped, what a mutation does besides mutating, and where the server/client line sits. This law pins those six decisions.
02
apiFetchlib/api.ts: native fetch + a zod schema for the response + one normalized failure shape. Malformed responses throw before entering the cache; every failure is an ApiError { status, code, message, retryable } — exactly what ErrorState and toast.error render. No raw fetch in components, no axios, no per-module client wrappers.
lib/query-keys.ts → createQueryKeys(scope) gives every domain the same hierarchy: all → lists() → list(params) / details() → detail(id). Invalidation grabs the right altitude (lists() after a write; all after a bulk change). Params are plain JSON-stable data — they ARE the cache key. Domains extend the returned object; they never invent a second root.
useAppMutationlib/use-app-mutation.ts makes the cross-class "mutations toast" law a default: success → toast.success with your copy; failure → toast.error named for the mutation, described by the ApiError, carrying a working Retry; settled → the invalidates keys refetch. Optimistic updates stay a recipe, not an abstraction: cancel in-flight reads, snapshot with getQueriesData, flip the cache, roll back from the snapshot in onError, reconcile via invalidates — worked example in /labs/foundations/data.
Static display data → fetch in the Server Component, pass props. Anything that changes after arrival (filters, polling, invalidation after a mutation) → client react-query. When an SSR'd first paint matters for a query-driven page, prefetch on the server (prefetchQuery + HydrationBoundary) and let the client take over — don't fetch the same data twice by hand.
useSuspenseQuery inside an AsyncBoundary is the default read: pending and failure belong to the boundary (skeletons if the shape is known), and data is never undefined in the component. Plain useQuery + isLoading is for inline refresh states only. The query client retries only retryable errors (5xx / 429 / network) — a 404 or contract violation fails fast.
Preferences → zustand / localStorage. Shareable context (filters, tabs, selection someone might link to) → URL. Server data never lands in zustand. Contracts live in one module per domain (zod schemas + keys next to the API, imported by both sides — see src/app/api/demo/notes/contract.ts), so client and server cannot drift silently.
03
createQueryKeys.useSuspenseQuery({ queryKey: keys.list(params), queryFn: ({signal}) => apiFetch(..., { schema, signal }) }) inside AsyncBoundary.useAppMutation with success copy, an errorTitle named for the action, and invalidates: [keys.lists()].04
fetch in a component, or a response consumed without its schema.["notes", filter]) — that's a second root the factory can't invalidate.useQuery's data while also copying it into state or a store — the cache IS the state.isLoading spinners where a suspense boundary + skeleton was possible.