When to reach for a store
An external store earns its place only when lifting state and context stop scaling — distant consumers, high-frequency updates that storm context, or persistence across routes. Otherwise it is over-engineering or duplicated server state.
You know how to share state in React: colocate it, lift it to the nearest common parent, and reach for context when the prop chain gets long. That ladder handles the overwhelming majority of state — and it’s where most apps should stop. Then someone adds Zustand, Jotai, or Redux Toolkit, and suddenly all the state lives there, because “global is easier”, and now you have two sources of truth and a re-render mystery to debug.
An external store is a real tool with a real job. But it is the fourth rung of the ladder, not the first. This lesson is about reading the exact pressures that mean lifting and context have stopped scaling — and the two failure modes that mean you reached for a store when you shouldn’t have.
After this lesson you can name the four pressures that justify an external store — state shared across distant branches, high-frequency updates that would storm context, persistence across route changes, and strong devtools/debugging needs; write a minimal Zustand store with selector-based subscriptions; and recognize the two failure modes — a global store for state two siblings share (over-engineering), and copying server state into the store (duplicated server state).
The default ladder is colocate → lift → context. A store is the rung you climb to only when those stop scaling. Most “we need a store” instincts are really “this state is two levels too low” or “this prop drilled four levels”. Lifting and context fix those for free, with no new dependency and no second source of truth. The senior move is to exhaust the ladder first.
// rung 1: colocate — state lives next to its only user
function SearchBox() {
const [q, setQ] = useState("");
return <input value={q} onChange={(e) => setQ(e.target.value)} />;
}
// rung 2: lift — nearest common parent owns shared state
// rung 3: context — stop prop-drilling low-frequency shared state (theme, auth user)A store is rung 4. You reach for it when rungs 1–3 produce either prop-drilling at depth you can’t refactor away, or a context whose updates re-render too much. Not before.
Pressure one: distant branches. When two far-apart subtrees share state, lifting forces it absurdly high and context becomes a wide net. If a command palette in the header and a detail panel in a deep route both need the same selection, the nearest common parent is the app root — so lifting there means threading props or a provider through everything between. A store lets each consumer subscribe directly, with no shared ancestor doing the plumbing.
// store consumed by two unrelated branches — no common parent in between
const useSelection = create<{ id: string | null; select: (id: string) => void }>(
(set) => ({ id: null, select: (id) => set({ id }) })
);
function HeaderPalette() {
const select = useSelection((s) => s.select); // subscribes to nothing that changes
return <button onClick={() => select("user-42")}>Jump to user</button>;
}
function DeepDetailPanel() {
const id = useSelection((s) => s.id); // re-renders only when id changes
return <p>Selected: {id ?? "none"}</p>;
}The store flattens the topology: subscription replaces a chain of intermediaries that don’t care about the state.
Pressure two: high-frequency updates that would storm context. Context has no selector — every consumer of a provider re-renders on every value change, even consumers that only read an unrelated field. For state that updates many times a second (a live cursor position, a dragging gesture, a streaming counter, a canvas), a single context turns one update into a tree-wide render storm. A store subscribes per-selector, so a component re-renders only when its slice changes.
// high-frequency: pointer moves fire dozens of times per second
const useCursor = create<{ x: number; y: number; move: (x: number, y: number) => void }>(
(set) => ({ x: 0, y: 0, move: (x, y) => set({ x, y }) })
);
function Crosshair() {
// only this component re-renders on move — siblings reading other slices don't
const { x, y } = useCursor((s) => ({ x: s.x, y: s.y }));
return <line style={{ transform: `translate(${x}px, ${y}px)` }} />;
}The same move through context would re-render every consumer of that provider, dozens of times a second. The store’s selector is what makes high-frequency state affordable.
Pressure three and four: persistence across routes, and devtools. A store lives outside the component tree, so it survives unmounts — and exposes a single inspectable state object. Lifted state and context state die when their owning subtree unmounts on a route change; preserving it means hoisting it above the router, which often means… a store. A store also gives you one place to inspect, time-travel, and log every transition — the Redux DevTools / Zustand middleware story — which is genuine value when state transitions are complex enough that “what changed and why” is a real debugging cost.
import { persist } from "zustand/middleware";
// survives route changes and reloads; one inspectable object
const useDraft = create(
persist<{ text: string; setText: (t: string) => void }>(
(set) => ({ text: "", setText: (text) => set({ text }) }),
{ name: "draft" } // persisted to storage, restored across routes/reloads
)
);These four pressures — distance, frequency, persistence, debuggability — are the only reasons that beat the simpler ladder. If none apply, you don’t have a store-shaped problem.
A theme toggle and user menu that need the same theme — store-first vs ladder-first. Two sibling components in a settings bar share one low-frequency value.
The store-first reflex over-engineers it:
// over-engineered: a global store for state two siblings share
const useTheme = create<{ theme: "light" | "dark"; toggle: () => void }>((set) => ({
theme: "light",
toggle: () => set((s) => ({ theme: s.theme === "light" ? "dark" : "light" })),
}));
function SettingsBar() {
return (<div><ThemeToggle /><UserMenu /></div>); // both reach into the global store
}It works, but now a global, app-wide singleton owns state that two adjacent components share — extra indirection, a dependency, and state reachable from anywhere it shouldn’t be. Lifting is strictly simpler:
// right-sized: lift to the nearest common parent, pass down
function SettingsBar() {
const [theme, setTheme] = useState<"light" | "dark">("light");
const toggle = () => setTheme((t) => (t === "light" ? "dark" : "light"));
return (
<div>
<ThemeToggle theme={theme} onToggle={toggle} />
<UserMenu theme={theme} />
</div>
);
}Now flip the pressures: if theme were read by dozens of distant components (rung 3, context — it’s low-frequency and broadly shared), or if it must persist across route changes and reloads (rung 4, a persisted store), the answer changes. The store earns its place only when the pressure is real. Here, two siblings sharing one value, the nearest common parent is right there — lifting wins, and reaching for a store is the over-engineering failure mode wearing a “global is easier” coat.
▸Common mistake
The second failure mode is subtler and far more common: copying server state into the store. Teams fetch GET /users, drop the array into Zustand, and now the store holds a stale snapshot they must manually invalidate, refetch, and reconcile — re-implementing caching, deduping, and revalidation badly. Server state is not client state: it’s owned by the server, can change behind your back, and needs caching with a freshness policy. That’s TanStack Query / SWR / RSC territory, not a store. Keep the store for genuinely client state — UI selection, drafts, toggles, ephemeral interaction — and let a server-cache library own anything that came over the wire. Mixing the two is how a store becomes a second, lying source of truth.
▸Edge cases
“High-frequency context is fine if I split providers and memoize” — partly true, and worth knowing where the line is. You can tame context by splitting state into many providers and wrapping every consumer in memo, and for a handful of fields that’s reasonable. But as the number of independently-updating fields grows, the provider-splitting tree becomes its own maintenance tax, and you’ve hand-rolled a worse version of what a selector-based store gives you for free. The store isn’t magic — it’s useSyncExternalStore with per-component selectors underneath. The judgment call is when the manual context-splitting cost exceeds the cost of one small store dependency; high-frequency, many-field state crosses that line fast.
Your team fetches a list of projects from GET /projects and wants it available across several screens, so a teammate stores the array in Zustand and refetches it manually on mount. Per this lesson, is the store the right home for this state?
The state ladder is colocate → lift → context, and it handles most state with no new dependency. An external store (Zustand, Jotai, Redux Toolkit) is the fourth rung, justified by exactly four pressures: state shared across distant branches with no sensible common parent, high-frequency updates that would storm a selector-less context, persistence across route changes and reloads, and strong devtools/debugging needs when transitions are complex. A minimal Zustand store gives per-selector subscriptions, so each component re-renders only on its own slice — which is what makes distance and frequency affordable. But the store has two failure modes that mark it as the wrong tool: reaching for a global singleton when two siblings could just lift state to their common parent (over-engineering), and copying server state into the store instead of letting a server-cache library own freshness (duplicated server state). A store earns its place only when lifting and context have demonstrably stopped scaling — match it to the pressure, not to the instinct that “global is easier”.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.