Skip to content

TanStack Query: Server State Management

Draw the server-state vs client-state boundary, cache keys, invalidation, and the failure modes that show up in real product UIs.

5 min read
TanStack Query
Server State
Caching
React
Invalidation

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

KindExamplesOwner
Server stateInvoice list, user profile, permissions, search resultsQuery (or framework loaders)
Client stateModal open, wizard step, ephemeral form draft before submitComponent state / small local store
URL stateActive tab, filters you want shareableRouter 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:

  1. Fetch: queryFn is pure enough to retry; errors are typed as failures, not undefined UI.
  2. Cache: staleTime / gcTime encode how wrong you allow the UI to be.
  3. Invalidate: after a mutation, say what became false.
  4. 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.