Prop Drilling Hits Four Levels: Building a Zustand Store
The sidebar's collapsed flag is needed in four places: the header toggle, the sidebar itself, a footer resize handle, and a keyboard-shortcut handler buried in the nav tree. Passing it as props means threading collapsed and onToggle through components that never use them — prop drilling — and every intermediate component rerenders each time the flag flips.
A store inverts the flow: components subscribe directly instead of receiving values through parents. Zustand is the smallest store that does this well.
npm install zustand
// stores/ui.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type UiState = {
sidebarCollapsed: boolean;
lastVisited: string;
toggleSidebar: () => void;
setLastVisited: (path: string) => void;
};
export const useUiStore = create<UiState>()(
persist(
(set) => ({
sidebarCollapsed: false,
lastVisited: '/',
toggleSidebar: () =>
set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
setLastVisited: (path) => set({ lastVisited: path }),
}),
{ name: 'ui-prefs' }
)
);
Two decisions in that shape carry the weight. Actions live in the store, not in components: toggleSidebar owns the transition, so any component can trigger it and none of them re-implements the logic — the same reason useReducer kept transitions in one place, now reachable from anywhere. And the state stays flat with primitive fields, which is what makes selector-based subscriptions cheap in the next chapter.
Any client component subscribes with a selector — one value per subscription:
'use client';
import { useUiStore } from '@/stores/ui';
export function SidebarToggle() {
const collapsed = useUiStore((s) => s.sidebarCollapsed);
const toggle = useUiStore((s) => s.toggleSidebar);
return (
<button onClick={toggle} aria-expanded={!collapsed}>
{collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
</button>
);
}
A component four levels deep can read the same flag with no parent involved, and toggling rerenders exactly the components that read the collapsed field. The persist middleware writes the slice to localStorage under the key ui-prefs, so the preference survives a reload — but that persistence collides with server rendering in specific, diagnosable ways, which chapter 3 covers.
Two persistence details while the options object is in view. persist serializes state with JSON, so the action functions are skipped automatically — only data round-trips to disk and back. And when a store mixes persisted and session-only fields, scope the write explicitly:
persist(initializer, {
name: 'ui-prefs',
partialize: (s) => ({ sidebarCollapsed: s.sidebarCollapsed }),
});
Without partialize, every new state field you add starts persisting by surprise — the wizard draft you meant to keep in memory ends up sitting in a user's localStorage for months.
What belongs in a store like this: UI preferences, multi-step wizard drafts, optimistic flags for mutations the server has not confirmed, a cart the client owns. What does not: server-owned data — the user list, the order history. The server already owns that truth and Server Components already fetch it with caching; copying it into a client store creates a second, staler copy. Keep the store for mutable state that must outlive a single component and that the server does not know about.
Zustand is not the only shape. Jotai flips the granularity: instead of one store with selectors, you declare atoms — independent units of state — and compose derived atoms from them:
// jotai shape, for contrast
const collapsedAtom = atom(false);
const navCountAtom = atom(
(get) => (get(collapsedAtom) ? 0 : 5)
);
Jotai suits many small values updated independently; Zustand suits updates routed through a handful of named actions. Both subscribe per read rather than per tree — the comparison the next chapter makes concrete against Context.
No Provider, No Rerender Storm: What the Zustand Hook Does Under the Hood
The first surprise for anyone arriving from Context: no <UiStoreProvider> wraps the app, nothing is added to the layout, and no createContext call exists anywhere. The store lives in module scope — a plain object with getState, setState, and subscribe that exists the moment any module imports it. The useUiStore(selector) hook bridges that object to React through useSyncExternalStore, the official API for subscribing to values owned outside React's render cycle. In Next.js that also means the store module is a singleton per runtime: in the browser it persists across client-side navigations, which is precisely why state in it survives navigation that would reset a page-level useState.
That architecture decides rerender behavior. When set produces a new state object, the store notifies every subscriber; each subscriber re-runs its selector and compares the result to its previous value with Object.is, rerendering only when its own slice changed:
const collapsed = useUiStore((s) => s.sidebarCollapsed); // rerenders on collapse only
const lastVisited = useUiStore((s) => s.lastVisited); // untouched by collapse
Context behaves differently, because a provider rerender hands every consumer the same new value object regardless of which field each consumer actually reads:
| Context + useState | Zustand store | |
|---|---|---|
| Provider in the layout | Required | None |
| Rerenders on change | Every consumer | Only changed-slice subscribers |
| Subscription granularity | Whole context value | One selector per read |
| Usable outside components | No | Yes — getState() / setState() |
| Extra wiring for devtools/transient updates | Hand-rolled | devtools / subscribeWithSelector middleware |
The selector is where the design pays off — and where the classic bug lives. Returning a fresh object from a selector defeats the Object.is comparison:
// wrong: a new object identity on every store notification
const { collapsed, lastVisited } = useUiStore((s) => ({
collapsed: s.sidebarCollapsed,
lastVisited: s.lastVisited,
}));
In development this surfaces as The result of getSnapshot should be cached to avoid an infinite loop warnings, because React sees a new snapshot on every call. Select one primitive at a time, or merge deliberately with useShallow:
import { useShallow } from 'zustand/react/shallow';
const { collapsed, lastVisited } = useUiStore(
useShallow((s) => ({
collapsed: s.sidebarCollapsed,
lastVisited: s.lastVisited,
}))
);
Actions have a stability property worth stating in an interview: the functions stored in the store keep the same identity forever, so const toggle = useUiStore((s) => s.toggleSidebar) returns a stable reference and passing it into a memoized child never invalidates that child. Non-React code — a raw event listener, a utility module, a test — reaches the same state without hooks via useUiStore.getState() and useUiStore.setState(), which is the practical payoff of the store living outside React.
On the server, the same hook renders client components during SSR without complaint: Zustand supplies the store's current state as the useSyncExternalStore server snapshot, so selectors return the initial values during prerendering and the browser's first render matches. One cost to respect: selectors re-run on every store notification for every subscriber. Keep them plain field reads; move any derivation over the selected slice into a useMemo on top, or a slow selector becomes a silent bottleneck at scale.
When an interviewer asks "why not Context?", do not answer that Context is bad. Context is the right tool for low-frequency, app-wide values read together — theme, locale, the signed-in user from a session. A store takes over at the point where the value changes frequently and scattered components each care about a different field, because a provider value rerendering the world on every keystroke is a scaling wall.
The Store Fighting the Server: Hydration, Persistence, and Module Scope
Pitfall 1: persisted state that disagrees with the server's first paint
persist reads localStorage on the client during the first render, while the server rendered the initial values. When a user returns with sidebarCollapsed stored as true, the server HTML says expanded and the client's first render says collapsed:
Warning: Prop `aria-expanded` did not match. Server: "true" Client: "false"
Hydration failed because the server rendered HTML didn't match the client.
The hydration contract from Local State with useState and useReducer applies to stores too: the first client render must equal the server's output. Two fixes, both standard:
persist(initializer, { name: 'ui-prefs', skipHydration: true });
'use client';
import { useEffect } from 'react';
import { useUiStore } from '@/stores/ui';
export function Hydration() {
useEffect(() => {
void useUiStore.persist.rehydrate();
}, []);
return null;
}
With skipHydration, the first client render matches the server, then the effect rehydrates and the UI corrects in a follow-up render. The alternative — rendering browser-dependent output only after a mounted flag flips — trades the same frame of fallback for more code at each read site.
Pitfall 2: stashing server data in the store
"Fetch the orders once on the client, keep them in the store, every page sees them." The store cannot deliver that: Server Components cannot read hooks, so any route's first paint still needs the data from the server anyway; the client copy has no invalidation story and drifts from the database; if persisted, it survives logout and leaks the previous user's rows. The framework already owns a cache for server data — fetch in a Server Component and pass props, or use an action plus revalidation tags for mutations. The store holds what the server does not know: UI state, drafts, optimistic flags — never the database's truth.
The optimistic pattern is worth separating from caching, because it looks similar and is not. Mark an item archived in the store immediately, fire the action, roll back if it errors: the store records the client's intent for a mutation in flight, the server stays the truth, and the UI simply refuses to wait for a round trip it is confident about.
Pitfall 3: module scope on the server means shared across requests
The same module-scope singleton property that makes the store fast in the browser is a correctness hazard on the server. Server modules persist for the lifetime of the process and are shared by every concurrent request, so reading or writing per-user data through a vanilla store (createStore() from zustand/vanilla) in a route handler or Server Action lets two users' requests race on one object — one user's cart appearing in another user's response. Global mutable state on the server is per-process by definition; keep per-user data in the request (session, database), and reserve server-side stores, if at all, for genuinely process-wide snapshots such as a feature-flag config.
One smaller trap from the same family: module state survives Fast Refresh in development, so a store holding a wizard draft can appear "stuck" after you edit code. A dev-only reset (useUiStore.setState(initialState, true) in an effect guarded by process.env.NODE_ENV) or a manual clear between test cases keeps the singleton honest.
All three pitfalls share one root: treating the client store as an extension of the server. Views that users identify by URL still belong in search params (Keeping Filters in the URL), and values that genuinely flow through the component tree from a shared parent may not need a store at all — that boundary is drawn precisely by Context Providers on the Client.
Quiz
1. A component does useUiStore((s) => ({ collapsed: s.sidebarCollapsed, open: s.modalOpen })). What happens when either field changes?
2. Where should the list of orders fetched from the database live?
3. A Zustand store with persist shows a hydration error because the user's stored sidebarCollapsed differs from the server's initial value. What is the standard fix?
4. Why is a module-scope store unsafe for per-user data inside a route handler?
Flashcards
Question
Where does a Zustand store live, and why does it need no provider?
Click to reveal answer
Answer
In module scope: a plain object with getState/setState/subscribe that exists as soon as the store module is imported. Components connect to it through useSyncExternalStore inside the store's hook, so no React Context or layout wrapper is involved — and because the module singleton survives client-side navigations, the state outlives page components.
Question
How does a selector decide whether a component rerenders?
Click to reveal answer
Answer
On each store notification the hook re-runs the selector and compares the result to the previous snapshot with Object.is. A changed primitive rerenders the component; an unchanged value bails out. Returning a fresh object from the selector always fails the comparison — select one field or wrap the selector in useShallow.
Question
What kinds of state belong in a global client store, and what explicitly does not?
Click to reveal answer
Answer
UI preferences, wizard drafts, optimistic mutation flags, carts the client owns — mutable state that must outlive one component and that the server does not know. Server-owned data does not belong there: fetch it in Server Components and invalidate through actions/revalidation, not through the store.
Question
Why does persist cause a hydration mismatch, and how do you fix it?
Click to reveal answer
Answer
The server renders the store's initial values while the client's first render reads localStorage, so the two HTML trees disagree when a stored value exists. Fix with skipHydration: true plus useUiStore.persist.rehydrate() in a post-mount useEffect, keeping the first client render identical to the server.
Question
Why is a module-scope store dangerous in route handlers or Server Actions?
Click to reveal answer
Answer
Server modules are singletons for the process lifetime, shared by all concurrent requests. Per-user reads and writes through one shared object race across users, leaking or clobbering data. Keep per-user state in the request (session, database); reserve process-global stores for genuinely global, non-user snapshots.
Revision Notes
Key Takeaways
- 1. The store is a module-scope singleton connected to React via useSyncExternalStore — no provider, and state survives client-side navigation by construction
- 2. Selectors plus Object.is comparison mean only components reading a changed slice rerender; Context rerenders every consumer on any value change
- 3. Actions belong in the store so transitions exist once and stay referentially stable for memoized children
- 4. Server data never enters the store — fetch it in Server Components and invalidate through actions; the store holds only client-mutable state
- 5. persist must skipHydration and rehydrate after mount, or stored values break the first-render-equals-server-HTML contract
Interview Tips
- • "Why Zustand over Context?" — the rerender model: per-selector subscriptions via useSyncExternalStore versus whole-value consumer notifications; then concede Context's ground (theme, user) so the answer shows judgment, not fandom
- • "What is the getSnapshot infinite-loop warning?" — the selector returns a new object every call so React always sees a fresh snapshot; fix by selecting primitives or useShallow
- • "How would you persist user preferences safely with SSR?" — skipHydration, rehydrate in useEffect, accept one corrective render; mention that the alternative is a mounted flag per read site
- • "Can a route handler use the store for the cart?" — no: module scope is process-global and shared across concurrent requests; per-user data lives in the request (session/database), and that same reasoning applies to any module-level mutable object on the server
Cheat Sheet
Global Client State with Zustand
Store shape:
export const useUiStore = create<UiState>()(
persist(
(set) => ({
sidebarCollapsed: false,
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
}),
{ name: 'ui-prefs', skipHydration: true } // SSR-safe
)
);
// subscribe with a selector (one primitive per read)
const collapsed = useUiStore((s) => s.sidebarCollapsed);
// multi-field: useShallow, never a bare object literal
const v = useUiStore(useShallow((s) => ({ a: s.x, b: s.y })));
// outside React
useUiStore.getState().toggleSidebar();
Why not Context: Context rerenders every consumer on any provider-value change; Zustand rerenders only components whose selected slice changed (Object.is on selector output via useSyncExternalStore). Context still wins for low-frequency app-wide values (theme, user, locale).
Placement: UI prefs, drafts, optimistic flags, client carts → store · view identity → URL params · database truth → Server Component fetch + revalidation.
Pitfalls: object-literal selector (infinite-loop warning) · persisted state hydration mismatch (skipHydration + rehydrate in effect) · per-user data in module scope on the server (shared across requests) · Fast Refresh leaving stale dev state.