State slicing and selectors: why one big store hurts
A component that reads "the whole store" re-renders on every change to that store, whether or not the fields it actually uses moved. This lesson walks the mechanism behind that waste in Zustand and Context, the fix — slicing state by change-frequency and subscribing with narrow selectors — and the transient-update escape hatch for state that changes too often to render at all.
State slicing and selectors: why one big store hurts
Put three unrelated things in one Zustand store — say, which sidebar panels are open, whether the websocket is currently connected, and the user's saved theme preference — and wire up three components, each reading one of those fields off the same useStore() call. Flip the connection status every few seconds, the way a real socket does, and watch the theme-settings panel re-render in lockstep, even though its own field never changed.
That's not a bug in Zustand. It's the direct, mechanical consequence of what "subscribing to a store" means by default: you're not subscribing to a field, you're subscribing to change itself, and every field lives behind the same tripwire.
Here's the shape of this lesson: why a broad subscription re-renders on unrelated changes, how that compares to the same problem in React Context, the fix — splitting state by how often it changes and what it's conceptually about, then narrowing each subscription with a selector — and the transient-update escape hatch for state that changes too fast to justify a render at all.
Why "read the whole store" means "re-render on everything"
useStore() called with no selector returns the entire store object. React's job, once a component reads a piece of external state, is to decide whether to re-render that component on the next update — and it makes that decision by checking whether the thing the component subscribed to changed. If what you subscribed to is "the whole store," then the check is trivially true on every single set() call, because Zustand's set doesn't mutate the existing store object in place — it produces a new one, merging your update into a fresh object with a new identity. Any component watching that top-level object sees a new reference on every update, no matter which key inside it actually moved.
This is easy to miss because nothing about it looks wrong from the outside. The panel-registry component, the connection-status component, and the settings component all render correct data — each one just renders more often than the data it displays ever changes. There's no error, no warning, no failed test. The only symptom is a render count that doesn't match your intuition, and intuition is exactly what doesn't flag this, because "I only use theme from this hook" feels like it should be enough information for React to act on. It isn't, not automatically — the store's default subscription granularity is the whole object, and nothing narrows it unless you tell it to.
The same failure, already familiar from Context
If this sounds like something you've seen before, it should — React Context has the identical failure mode, just with no way out of it. Every component that calls useContext(SomeContext) re-renders whenever the nearest Provider's value prop changes, full stop. There is no mechanism by which a single Context can let some consumers skip an update because the specific field they read didn't change — the value you hand to the Provider is one unit for re-render purposes, indivisible, no matter how many unrelated fields you've packed into it.
That's precisely why the standard advice is to reach for Context for low-frequency values — a theme name, the current authenticated user, a locale — and to avoid it for anything that changes often. A theme flips once in a blue moon; a hundred components re-rendering on that rare flip costs nothing. A value that changes every few seconds, wrapped in the same Context, would drag every consumer through that same all-or-nothing re-render on a schedule, with no selector to escape it. Zustand's store has the same "everything is one unit" failure mode as its default — the difference, and the entire subject of this lesson, is that a store hook lets you narrow what you're watching down to a single field, and Context simply doesn't offer that knob.
Slicing by frequency and by concept
The fix starts before you write a single selector: it starts with not putting unrelated state in the same store in the first place. State belongs together when it changes for the same reasons, at roughly the same rate, and gets read by the same parts of the UI — not just because it's all "global."
Take the three pieces of state from the opening example, because they're a genuinely common shape:
- Panel registry — which sidebar panels are open or collapsed. Changes on user clicks: rare, bursty, tied to layout.
- Connection status —
"connected" | "reconnecting" | "offline", updated by a websocket heartbeat every few seconds. Changes constantly, tied to network conditions, not to anything the user did. - Settings — theme, notification preferences, display density. Changes almost never, usually from one dedicated settings screen.
Nothing about these three is conceptually related, and their update frequencies span three different orders of magnitude. Lump them into one store anyway — easy to do, since Zustand makes a single create() call feel like the natural home for "app-wide state" — and the connection-status heartbeat, ticking every few seconds, becomes a standing tax on every component that reads anything from that store, including the settings panel that changes once a session. The fix is structural, not clever: give each of these its own store (or, if they must share a file, treat them as clearly separate slices combined into one store's shape rather than one flat bag of fields).
// Three separate stores, not one — because they change for different reasons.
import { create } from 'zustand'
const usePanelStore = create((set) => ({
openPanels: new Set<string>(),
togglePanel: (id: string) =>
set((s) => {
const next = new Set(s.openPanels)
next.has(id) ? next.delete(id) : next.add(id)
return { openPanels: next }
}),
}))
const useConnectionStore = create((set) => ({
status: 'connected' as 'connected' | 'reconnecting' | 'offline',
setStatus: (status: typeof useConnectionStore.getState().status) => set({ status }),
}))
const useSettingsStore = create((set) => ({
theme: 'dark' as 'dark' | 'light',
setTheme: (theme: 'dark' | 'light') => set({ theme }),
}))With this split, a heartbeat calling useConnectionStore.getState().setStatus(...) every few seconds only touches useConnectionStore's subscribers. The settings panel, subscribed to a different store entirely, never sees that store's identity change, because it was never watching it. Frequency-driven noise stops leaking across conceptual boundaries — not because any single subscription got smarter, but because the noisy state and the quiet state no longer share a store to leak through.
Selectors: narrowing what a component actually watches
Splitting stores handles state that's obviously unrelated. Selectors handle the more common case: state that reasonably lives in the same store, where different components still only need one field each. A selector is a plain function you pass to the store hook — useStore(state => state.connectionStatus) — and it changes what the hook compares between renders. Instead of comparing "did the store object's identity change," the hook now compares "did the value the selector returned change," using a reference-equality check by default. Only if that selected value is different from what it was last render does the component re-render.
// Broad subscription: re-renders on ANY change to any field in this store.
function StatusBadgeBroad() {
const state = useConnectionStore()
return <span>{state.status}</span>
}
// Narrow subscription: re-renders ONLY when `status` itself changes.
function StatusBadgeNarrow() {
const status = useConnectionStore((state) => state.status)
return <span>{status}</span>
}Say this store also held a latencyMs field that updates on every heartbeat tick. StatusBadgeBroad re-renders on every tick, because it read the whole state object and the object's identity changes every time set() runs, regardless of which field moved. StatusBadgeNarrow re-renders only when status transitions between "connected", "reconnecting", and "offline" — a latencyMs update on its own changes nothing the selector returns, so the reference-equality check sees the same value it saw last time, and the component is left alone. The selector is what turns "the store changed" into "the specific field this component cares about changed," and that translation is the entire mechanism — nothing about Zustand's internals gets smarter, you're just handing it a narrower question to answer.
Transient updates: skipping the render entirely
Selectors reduce how many components re-render on a given change, but they still go through React's render pipeline for the component that does care. Some state changes too often to justify a render even for its one legitimate subscriber — a live cursor position broadcast over a socket, a scroll-linked value, anything ticking at tens of updates per second. For that, Zustand exposes store.subscribe(...), a callback that fires on every state change, called outside the useStore hook entirely — typically wired up inside a useEffect — so you can react to the store without asking React to re-render anything.
function LiveLatencyReadout() {
const spanRef = useRef<HTMLSpanElement>(null)
useEffect(() => {
// Not useStore — this bypasses React's render pipeline entirely.
const unsubscribe = useConnectionStore.subscribe((state) => {
if (spanRef.current) {
spanRef.current.textContent = `${state.status} (${state.latencyMs}ms)`
}
})
return unsubscribe
}, [])
return <span ref={spanRef} />
}This is the same trade this track already made once, one level up the stack: Refs vs. state: why the hot path skips setState showed a drag handler writing straight into a ref and the DOM, bypassing setState because a pointer stream firing 60–100 times a second doesn't need React's render, diff, and commit machinery run on every tick. store.subscribe outside of useStore is that identical idea, applied at the store level instead of the per-component ref level: the store keeps updating on every tick, a plain callback reads the new value and writes it straight to a DOM node you already hold a ref to, and React's render pipeline never gets invoked for that update at all. You're not narrowing what triggers a render anymore — you're opting a piece of state out of triggering one in the first place.
What selectors cost you, precisely
Broad subscription (useStore()) | Narrow subscription (useStore(selector)) | |
|---|---|---|
| Re-renders on | any change to any field in the store | only when the selected value's reference changes |
| Correctness if you forget | still correct — just wasteful | correct, and only as narrow as the selector you wrote |
| Failure mode | silent — no error, just extra renders | silent — a selector that's too broad (returns a fresh object) silently reverts to "re-render on everything," with no warning |
| Effort | zero — the default | one discipline: write (and keep writing) a narrow selector for every new subscriber |
The row worth sitting with is the third one. Selectors don't add a safety net — they add a manual habit. Forget to narrow a selector, or add a new field to the store and reach for the broad useStore() call out of convenience "just this once," and you're silently back to re-rendering on everything, exactly where you started. Nothing throws, nothing lints by default, and the component still renders the right data — it just does so more often than it needs to, which is the same failure this whole lesson opened with, just reintroduced one call site at a time.
Go deeper
- Zustand — official documentation — The primary source for the store/selector API this lesson's code examples are built on.
- Zustand — auto-generating selectors guide — A concrete pattern for keeping narrow selectors ergonomic at scale, rather than hand-writing one for every field.
- React docs — Passing Data Deeply with Context — The official description of Context's all-consumers-re-render behavior this lesson contrasts selectors against.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- A component calls useStore() with no selector and only renders one field from the result. Why does it still re-render when an unrelated field in the store changes?
- Explain, at the mechanism level, why Context has no equivalent of a Zustand selector — why can't some useContext consumers skip a re-render based on which part of the value changed?
- Given a panel-open registry, a connection-status flag, and a rarely-changing settings object, why is putting all three in one store worse than the sum of its parts, rather than just 'a bit wasteful'?
- What, specifically, does a Zustand selector change about how the store hook decides whether to re-render a component?
- A selector returns a brand-new object built inline on every call: useStore(state => ({ a: state.a, b: state.b })). Why does this still re-render on every store update despite looking narrow?
- What does store.subscribe(...) called inside a useEffect skip that useStore(selector) does not, and what earlier pattern in this track does that mirror?
- Two engineers both use selectors, but one still re-renders far more than expected. What's the most likely explanation — and why wouldn't Zustand warn about it?