Skip to content

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

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-GET requests, so reads and writes must use the right HTTP method
  • Form state is a separate decision (ADR-0013)

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 handles redirect() 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-deprecated ensureQueryData, fetchQuery, and prefetchQuery, so many examples online are outdated

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

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.

Pros: small, simple. Cons: fewer features than Query (mutations, infinite lists, devtools are thinner) and no official TanStack Router SSR integration.

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 validateSearch directly
  • 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)

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().

Pros: atoms give fine-grained re-renders. Cons: a different mental model; 3.0 shipped in 2026-09 and is still settling.

Pros: used inside TanStack’s own libraries. Cons: pre-1.0 (0.11.2, 1.0 in alpha); thin docs for app-level use.

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-query 5.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 QueryClient per request, created inside getRouter() in src/router.tsx, passed in router context, and wired with setupRouterSsrQueryIntegration({ router, queryClient }). Never a module-level QueryClient — on the server it would share one user’s data with the next
  • Query owns the data; Router coordinates. Set defaultPreloadStaleTime: 0 on the router so every preload defers to Query’s freshness. Loaders await context.queryClient.query({ ...options, staleTime: "static" }) to fill the cache (cached data is used as-is, missing data is fetched); components read with useSuspenseQuery, not Route.useLoaderData(). Use queryClient.query(), not the deprecated ensureQueryData / prefetchQuery / fetchQuery
  • Default staleTime of 30 s on the QueryClient, 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. Plain useQuery does 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 GET server functions, writes are POST. queryFn calls a createServerFn() (default GET) that never writes. Mutations call createServerFn({ method: "POST" }) through useServerFn in useMutation, 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() plus router.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 through loaderDeps and 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 createStore inside a provider (useState(() => createStore(...))) and read them with useStore — never a module-level create() 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.

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 ensureQueryData and prefetchQuery; 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-query upgrade together because of peer ranges
  • staleTime is 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.