Context vs client store vs server state
- Name each value's single owner (URL, query cache, client store or Context) from who creates it
- Replace server data copied into a store with stored ids joined to the query data at render time
- Write a query key that includes every request input, such as the status filter and page number
- Split a context into state and dispatch so dispatch-only consumers skip re-renders when state changes
- Spot a module-level store outliving its screen, and a persisted value read before mount under SSR
Read
Ask "who owns this data?" before "where do I put it?"
FlagOnce state has outgrown a single component you're not picking a library, you're picking an owner. Three of the four candidates are a React API or a library you add; the fourth is already in the browser, it's the one people forget, and it's often the right answer.
| Owner | Owns | The question it answers |
|---|---|---|
| Context | Transport for values that rarely change | "How does this reach deep components without prop-drilling?" |
| Client store (Zustand/Redux) | Client-only state many components read and write | "Who is the single source of truth for this app-wide value?" |
| Server state (TanStack Query) | A cache of data the server owns | "Is my copy fresh, and how do I refetch it?" |
| The URL | Navigation state: what you're looking at | "Can I paste this as a link, and does Back work?" |
The mistake is treating them as ranked (Context → store → Query as increasing power). They're not interchangeable:
- Context is a delivery mechanism, not a store — it has no selectors, so every consumer
re-renders when the value's identity changes. Perfect for a theme, a locale, a
queryClient, a stable dependency. Bad for anything that ticks. - A client store is for state your app invents: sidebar open, selected rows, a multi-step wizard's draft (cleared when the wizard ends), undo stack.
- Server state is for data your server invents. It's a cache, and caches need staleness, deduping, retries and invalidation — all things you'd otherwise hand-roll.
- The URL owns where the user is: filters, tab, page, selected id. It's the only owner a colleague can receive over Slack.
Rule of thumb: if a refresh of the page should be able to re-derive the value from the API, it's server state. If it must be re-derived from the URL, it's URL state. Whatever's left is client state — and only that is a Context/store decision.
What each wrong choice actually costs you
FlagEvery misplacement has a recognizable symptom. Learn the symptoms and you can diagnose a codebase in an afternoon.
Context holding hot state
Symptom: the whole page re-renders on every keystroke/tick; React.memo on the
consumers changes nothing.
Context re-renders all consumers when the provider's value changes identity — there is no per-field subscription, and memoizing the consumer can't help, because context updates bypass the memo bail-out.
Be precise about which problem you have, because the fixes aren't interchangeable:
- The value's identity churns but its contents don't (a fresh
{ user, logout }object every provider render).useMemoon the value is the fix — that's thecontext-value-rerenderchallenge from the previous unit. - The value itself genuinely changes often (a draft string, a timer, a cursor position).
useMemocannot help you here: the value really is new, so every consumer really does re-render. Split one context intoStateContext+DispatchContextso action-only consumers stop re-rendering, split the hot field into its own narrow context, or move it into a store with selectors — the only option that gives you per-field subscriptions.
Server data copied into a client store
Symptom: "why is this list stale?" bugs, a useEffect that fetches and setStates,
and manual refresh buttons.
// ❌ two sources of truth
const { data } = useQuery({ queryKey: ["orders"], queryFn: getOrders });
useEffect(() => { if (data) setOrders(data); }, [data]); // now YOU own freshness
Keep the query as the source; put only the client's decisions (selected ids, filters) in the store, and join them at render time. If you need an instant UI response to a write, that's an optimistic update in the mutation — not a second copy in a store.
Client state that should have been in the URL
Symptom: "send me a link to that filtered view" is impossible, and back/forward do the wrong thing.
Filters, tabs, pagination, and the selected entity are navigation state. In the URL they are shareable, bookmarkable, restorable, and get browser history for free.
A store used for a single screen
Symptom: state that outlives the screen. You open the wizard again and it's still half-filled; tests need a store reset between cases.
A module-level store is a singleton. That's a feature for app-wide state and a bug for screen-local state — prefer local state (or a store created per screen and thrown away with it). A flow that spans routes, like that wizard, can still use a store: create it in the wizard's shared layout, or clear it when the flow finishes or is cancelled.
SSR / hydration
Symptom: a hydration error in the console — React 19 reports "Hydration failed because the server rendered text didn't match the client" (React 18 warned "Text content did not match") — and the tree re-rendered from scratch on the client. If only an attribute differs (a theme class), React 19 logs that the attributes didn't match and leaves the server's value in place.
Anything read from localStorage (a persisted store, a theme) doesn't exist on the server.
Render the server's default, then apply the persisted value after mount — a hydration gate.
The cost that's left is a brief flash of the default. If that's unacceptable (a theme), the
server has to know the value up front, which means a cookie rather than localStorage, or
a small inline script has to apply it before the first paint.
Server state has the parallel version of this: prefetch on the server and hydrate the cache, rather than fetching again on the client.
Real screens combine all four owners — cleanly
FlagA production list screen usually has all of these at once. The skill is keeping the seams clean: each value has exactly one owner, and the others derive from it. Here it is as a Next.js App Router client component with TanStack Query v5:
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
function OrdersScreen() {
// 1. URL owns navigation state — shareable, restorable, back/forward for free.
// Next.js's useSearchParams is read-only: you change the URL by navigating.
const params = useSearchParams();
const router = useRouter();
const pathname = usePathname();
const status = params.get("status") ?? "open";
const page = Number(params.get("page") ?? 1);
const setStatus = (next: string) => {
const query = new URLSearchParams(params.toString());
query.set("status", next);
query.delete("page"); // a new filter starts at page 1
router.push(`${pathname}?${query.toString()}`);
};
// 2. Server owns the data. The query key includes everything the request depends on,
// so changing a filter is just a new cache entry — no manual refetch.
const { data, isPending } = useQuery({
queryKey: ["orders", { status, page }],
queryFn: () => getOrders({ status, page }),
});
// 3. The client store owns choices the app invented, keyed by id — never row copies.
const selectedIds = useSelectionStore((s) => s.selectedIds);
// 4. Derive at render time. No third copy of anything.
const selectedOrders = data?.items.filter((o) => selectedIds.has(o.id)) ?? [];
…
}
The seams that keep this honest
- Store ids, not entities.
Set<string>of selected ids survives a refetch; a copy of the selected rows goes stale the moment the query updates. - Put every request input in the query key.
["orders", { status, page }]makes the cache correct by construction, and invalidation (invalidateQueries({ queryKey: ["orders"] })) matches by prefix, so it hits every page/filter variant at once. - Writes go through mutations, not the store. TanStack Query v5 documents two ways to
show a write before the server confirms it: render the pending mutation's
variables(simplest, when one place shows the change), or patch the cache inonMutateand keep a rollback (when several places do). Neither needs a parallel store that the mutation must remember to update. - Context carries the clients.
QueryClientProviderand an auth/theme provider sit at the root — dependencies, not state.
When a store is the right answer for shared data
Cross-cutting, client-owned, high-write values: a toast queue, undo/redo, drag state, a canvas editor's selection and tool state. These aren't server truth, don't belong in the URL, and update too often for Context.
Cost of getting it wrong, ranked
- Two sources of truth (server data mirrored in a store) — permanent staleness bugs.
- Navigation state not in the URL — an unfixable class of UX complaints.
- Hot state in Context — a performance problem, but a mechanical fix.
- A store for one screen — leaks between visits; easy to localize later.
The first two are architectural; the last two are refactors. Spend your review attention accordingly.
Quiz
A table's status filter and page number must be shareable as a link and survive a refresh. Who owns them?
FlagWhat's wrong with useEffect(() => { if (data) setOrders(data) }, [data]) after a useQuery?
A selection store keeps track of chosen table rows. Store what?
FlagWhich value is Context genuinely the RIGHT tool for?
FlagA three-step wizard's draft must survive navigating between its steps (separate routes) but should start empty each time the user opens the wizard. What's the trap with a module-level store?
FlagA theme is persisted to localStorage and the app is server-rendered. Why does reading it during the first client render cause a hydration mismatch?
FlagFill in the blank
Every input the request depends on goes in the query key, so each filter/page combination is its own cache entry.
FlaguseQuery({ queryKey: ["orders", { , }], queryFn: () => getOrders({ status, page }), });
The store holds selected ids; derive the selected rows from the server data at render time.
Flagconst selectedIds = useSelectionStore((s) => s.selectedIds); // Set<string> const selected = data.items.((o) => selectedIds.(o.id));
Split one context in two so that components which only dispatch actions stop re-rendering when the state changes.
Flag<StateContext.Provider value={state}> <.Provider value={dispatch}>{children}</.Provider> </StateContext.Provider>