ADR-0012 Data fetching & state
Company standard data fetching and state — TanStack Query behind Router loaders for server data, the URL and React for client state, Zustand only when needed.
- Decision
- TanStack Query + loaders; URL + React stateBuild the app
- Status
- Accepted
- Date
- Scope
- Company-wide default for all new apps
Connections
Context
Section titled “Context”TanStack Start (ADR-0002) gives us route loaders and server functions; our UI reads and writes through server functions, and the server routes at /api/* are for external clients (ADR-0036). What is still open is who owns the data once it reaches the browser, and where UI-only state lives.
Two kinds of state, with different needs:
- Server state — data whose source of truth is Postgres (ADR-0007). Needs caching, deduplication, refetching when stale, and invalidation after writes. Must render on the server and stream with SSR, then hydrate without fetching twice
- Client state — UI state that never touches the server: filters, tabs, open panels, wizard steps. Some of it should survive a reload or a shared link; most of it is local to one component
Constraints:
- SSR serves many users from one process. Anything created at module level on the server is shared between requests — a cache or store holding user data there leaks it to the next user
- Writes are rate-limited, reads are not (ADR-0010): the limiter covers non-
GETrequests, so reads and writes must use the right HTTP method - Form state is a separate decision (ADR-0013)
Options considered
Section titled “Options considered”Server state
Section titled “Server state”1. TanStack Query, with Router loaders prefetching
Section titled “1. TanStack Query, with Router loaders prefetching”Loaders start the fetch with queryClient.query(); components read the cache with useSuspenseQuery.
Pros
- One cache per entity, shared by every route and component that asks for the same key — with deduplication, background refetch, polling, infinite lists, and mutations built in
- Official SSR integration (
@tanstack/react-router-ssr-query): dehydrates on the server, streams queries that resolve during render, hydrates on the client, and handlesredirect()thrown from queries - Loaders still coordinate navigation and preloading, so no request waterfalls
- Same maintainers as Router and Start; MIT; 5.x is mature (5.104.5)
Cons
- Two caches exist (Router’s loader cache and Query’s); we must pick one owner — see Decision
- Query keys are our discipline: a mismatched key means a duplicate fetch or a stale screen
- The imperative API moved recently:
queryClient.query()replaces the now-deprecatedensureQueryData,fetchQuery, andprefetchQuery, so many examples online are outdated
2. Router loaders only
Section titled “2. Router loaders only”Router’s built-in loader cache (stale-while-revalidate) plus router.invalidate() after writes.
Pros
- No extra dependency; one cache, one mental model
Cons
- Cached per route match, not per entity — two routes showing the same record fetch it twice and go stale independently
- No mutation state, optimistic updates, polling, or infinite-list tooling; each product rebuilds them
3. TanStack DB
Section titled “3. TanStack DB”Reactive client-side collections with live queries and optimistic writes, built on Query.
Pros: live joins across collections in the browser; instant optimistic UI.
Cons: pre-1.0 (@tanstack/react-db 0.5.11) — the API can still change under a company standard. It builds on Query, so starting with Query keeps it open.
4. SWR
Section titled “4. SWR”Pros: small, simple. Cons: fewer features than Query (mutations, infinite lists, devtools are thinner) and no official TanStack Router SSR integration.
Client state
Section titled “Client state”A. URL search params + React state, a store only when needed
Section titled “A. URL search params + React state, a store only when needed”Shareable state in search params, validated by Zod in validateSearch; local state in useState, useReducer, or context.
Pros
- No dependency; filters, tabs, and pages survive reloads, back/forward, and shared links
- Search params are typed and validated by the same Zod schemas we use elsewhere (ADR-0005); Zod 4 schemas plug into
validateSearchdirectly - Most “global state” turns out to be server state (Query) or URL state — little is left for a store
Cons
- Teams must agree when state is big enough for a store; needs a written rule (see Decision)
B. Zustand as the standard store
Section titled “B. Zustand as the standard store”Pros: tiny, familiar, unopinionated; 5.x stable (5.0.15).
Cons: a store from day one invites copying server data into it, creating a second, stale cache. Under SSR it must still be created per request, not as a module-level create().
C. Jotai
Section titled “C. Jotai”Pros: atoms give fine-grained re-renders. Cons: a different mental model; 3.0 shipped in 2026-09 and is still settling.
D. TanStack Store
Section titled “D. TanStack Store”Pros: used inside TanStack’s own libraries. Cons: pre-1.0 (0.11.2, 1.0 in alpha); thin docs for app-level use.
Decision
Section titled “Decision”TanStack Query, prefetched by Router loaders, owns server state. The URL and React state own client state, with Zustand only when cross-tree client state really appears. This is the company standard for all new apps.
Concretely:
- Packages:
@tanstack/react-query5.x and@tanstack/react-router-ssr-query. Their peer ranges tie them to Router and Query versions, so bump Query, Router, Start, and the integration together - One
QueryClientper request, created insidegetRouter()insrc/router.tsx, passed in routercontext, and wired withsetupRouterSsrQueryIntegration({ router, queryClient }). Never a module-levelQueryClient— on the server it would share one user’s data with the next - Query owns the data; Router coordinates. Set
defaultPreloadStaleTime: 0on the router so every preload defers to Query’s freshness. Loaders awaitcontext.queryClient.query({ ...options, staleTime: "static" })to fill the cache (cached data is used as-is, missing data is fetched); components read withuseSuspenseQuery, notRoute.useLoaderData(). UsequeryClient.query(), not the deprecatedensureQueryData/prefetchQuery/fetchQuery - Default
staleTimeof 30 s on theQueryClient, so data rendered on the server is not refetched the moment it hydrates. Tune per query - Data needed on first render uses
useSuspenseQuery— it runs on the server and streams. PlainuseQuerydoes not run on the server; keep it for client-only data (e.g. polling after load) - Query options live in factories next to the feature —
queryOptions({ queryKey, queryFn })— and are shared by the loader, the component, and invalidation. Keys start with the entity:["projects", projectId] - Reads are
GETserver functions, writes arePOST.queryFncalls acreateServerFn()(defaultGET) that never writes. Mutations callcreateServerFn({ method: "POST" })throughuseServerFninuseMutation, so every write passes the rate limiter (ADR-0010) - After a write, invalidate the owner:
queryClient.invalidateQueries({ queryKey }), returning the promise so the mutation stays pending until the refetch ends.router.invalidate()only when route context changes too. Confirmed write plus invalidation is the default; optimistic updates only where the UX needs them, with rollback - Sign-in and sign-out reset everything:
queryClient.clear()plusrouter.invalidate(), so no private data from the previous identity stays in the cache - Shareable client state lives in the URL: Zod 4 schemas passed straight to
validateSearch, using.catch()for fallbacks so a malformed link shows defaults, not an error. Don’t install@tanstack/zod-adapter— it exists for Zod 3. Pass the search values a loader depends on throughloaderDepsand into the query key - Local state uses
useState/useReducer, lifted or put in context when siblings share it - Zustand 5 only for cross-tree client state that is neither server data nor URL-worthy (e.g. an editor’s selection, a multi-step canvas). Create stores with
createStoreinside a provider (useState(() => createStore(...))) and read them withuseStore— never a module-levelcreate()store. Never copy server data into a store
The deciding factors: one per-entity cache with mutations and SSR streaming built in, maintained by the same team as our router; URL state that is typed by the Zod schemas we already have; and no global store until a product proves it needs one. We accept owning query-key discipline and keeping Query and Router versions in step.
Consequences
Section titled “Consequences”Easier:
- Data loads in parallel with navigation, renders on the server, streams, and hydrates without a second fetch
- Mutations, loading and error states, retries, polling, and infinite lists come from one library in every product
- Filters, tabs, and pagination are linkable and survive reloads; their types come from Zod
- TanStack DB stays open: it builds on Query, so a product can adopt it later without changing the data layer
Harder:
- Query-key discipline: one factory per query, one key shape per entity, enforced in review
- Most online examples still use
ensureQueryDataandprefetchQuery; they still work but are deprecated and will be removed in Query’s next major - Every request builds its own
QueryClient; a module-level client or store is a data leak between users, not just a bug — review for it - Query, Router, Start, and
@tanstack/react-router-ssr-queryupgrade together because of peer ranges staleTimeis a product decision per query; too low refetches constantly, too high shows old data
Given up: the single-cache simplicity of Router loaders alone, TanStack DB’s live queries until it reaches 1.0, and the convenience of one global store. Accepted — per-entity caching and mutation tooling are worth one library, and most global state belongs in Query or the URL anyway.

