TanStack Query is a cache with rules. The harder question is the boundary itself: what must live on the server side of that line, what must stay local, and what goes wrong when teams dump both into one global store "for consistency."
The useful test is not "we use Query." It is whether you can name ownership before you name a library.
The ownership test
Ask one question for every piece of state:
If the network is down and I refresh, who still has the truth?
| Answer | Kind | Home |
|---|---|---|
| The API / database | Server state | Query, loader, or RSC fetch, not Redux |
| This browser tab only | Client state | useState, URL, or a small UI store |
| The address bar | URL state | Router search / path params |
If two screens need the same remote bytes, that is still server state, shared via a cache key, not via a hand-synced slice.
ts// src/state/classify.ts, illustrative decision helper export type StateHome = 'server' | 'client' | 'url'; export function classifyState(opts: { ownedByServer: boolean; mustSurviveRefresh: boolean; shouldBeShareable: boolean; }): StateHome { if (opts.shouldBeShareable) return 'url'; if (opts.ownedByServer || opts.mustSurviveRefresh) return 'server'; return 'client'; }
mustSurviveRefresh for remote data means "refetch or hydrate from cache," not
"serialize into localStorage as a fake backend."
What Query (or loaders) should own
I put these on the server-state side by default:
- Lists and details fetched from an API
- Permissions / session profile used across routes
- Search results keyed by the same filters as the request
- Anything another user or tab could change underneath you
tsx// features/projects/ProjectList.tsx, illustrative import { useQuery } from '@tanstack/react-query'; import { projectKeys, projectsQuery } from '@/queries/projects'; export function ProjectList({ q }: { q: string }) { const projects = useQuery(projectsQuery({ q })); if (projects.isPending) return <p>Loading projects…</p>; if (projects.isError) { return <p role="alert">{projects.error.message}</p>; } return ( <ul> {projects.data.map((p) => ( <li key={p.id}>{p.name}</li> ))} </ul> ); } // filters in the URL, client/URL state, not in the query cache as "source" // queryKey still includes `q` so cache entries stay honest void projectKeys.list({ q });
The filter string is URL state. The result set is server state keyed by that filter. Mixing them, storing results in Zustand and filters only in React state, is how you get "works until someone shares the link."
What must stay local
Keep these out of the server cache:
- Modal / drawer open
- Wizard step before submit
- Ephemeral form drafts (until mutation succeeds)
- Hover, focus traps, transient animation flags
- "Optimistic UI chrome" that is not yet acknowledged by the server
tsx// features/projects/CreateProjectDialog.tsx, illustrative local ownership import { useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { projectKeys } from '@/queries/projects'; export function CreateProjectDialog() { const [open, setOpen] = useState(false); const [name, setName] = useState(''); const qc = useQueryClient(); const create = useMutation({ mutationFn: async (input: { name: string }) => { const res = await fetch('/api/projects', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(input), }); if (!res.ok) throw new Error('Create failed'); return res.json(); }, onSuccess: async () => { setOpen(false); setName(''); await qc.invalidateQueries({ queryKey: projectKeys.all }); }, }); return ( <> <button type="button" onClick={() => setOpen(true)} > New project </button> {open ? ( <form onSubmit={(e) => { e.preventDefault(); create.mutate({ name }); }} > <input value={name} onChange={(e) => setName(e.target.value)} aria-label="Project name" /> <button type="submit" disabled={create.isPending} > Create </button> </form> ) : null} </> ); }
open and name die with the dialog. The project list lives in Query. That
split is the whole post.
Failure mode: server state in a global store
The pattern I keep deleting:
fetchinuseEffectdispatch(setProjects(data))- Every screen reads
useSelector(selectProjects) - Nobody knows when the data is stale
- Two screens refetch and race into the same slice
You rebuilt a cache without keys, staleTime, or structured invalidation - then
blamed React for re-renders.
Dumping server payloads into Redux/Zustand "so everything is in one place" optimizes for a diagram, not for truth. One place with no invalidation story is a single point of quiet corruption.
Framework loaders and the same boundary
Remix/RR loaders and Next server data do not erase the line, they move the first paint across it.
- Loader / RSC payload = server state for the route
- Query = client cache for shared remote data and subsequent navigations
- Local UI state still does not belong in either
Fighting loaders and Query with different keys for the same resource is how you get double fetches and disagreeing UIs. Pick one cache identity per resource.
Decision criteria I use in review
- Remote + shared? → server-state library or loader, not a global UI store.
- Ephemeral + single screen? → component state.
- Shareable / bookmarkable? → URL.
- Needs offline draft? → explicit persistence layer with a schema, not
"shove the Query data into
localStorage." - Needs optimistic UX? → mutate cache by key after a clear success path; do not invent a second source of truth.
Continuity
Query is one implementation. The judgment call that survives library churn is naming ownership before you name a library. Component libraries (primitives versus domain components) sit on top of honest state boundaries.
Takeaway
Server state is owned by the network; client state is owned by the session. Caches with keys beat global stores that pretend both are the same shape.