Skip to content

Client vs Server State: Boundaries

Deepen the Query boundary, when remote data belongs in a cache versus local UI state, and how global stores fail when they absorb both.

5 min read
Server State
Client State
TanStack Query
React
State Management

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?

AnswerKindHome
The API / databaseServer stateQuery, loader, or RSC fetch, not Redux
This browser tab onlyClient stateuseState, URL, or a small UI store
The address barURL stateRouter 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:

  1. fetch in useEffect
  2. dispatch(setProjects(data))
  3. Every screen reads useSelector(selectProjects)
  4. Nobody knows when the data is stale
  5. 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

  1. Remote + shared? → server-state library or loader, not a global UI store.
  2. Ephemeral + single screen? → component state.
  3. Shareable / bookmarkable? → URL.
  4. Needs offline draft? → explicit persistence layer with a schema, not "shove the Query data into localStorage."
  5. 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.