State management · Client-state libraries
Zustand
⚡ TL;DR — Zustand is a ~1KB store built on a plain closure and
useSyncExternalStore: youcreatea store, components subscribe with a selector, and only components whose selected slice changed re-render — no Provider, no reducers, no boilerplate, no Context re-render tax.
🧠 Mental model
Zustand is "just a subscribable JavaScript object with a React hook bolted on." The store lives outside React in a closure — no <Provider> wrapping your tree. Components opt in by calling the hook with a selector that picks the slice they care about. Zustand compares the selected value across renders and re-renders the component only when it changes. That external-store-plus-selector design is precisely what Context can't do: Context re-renders every consumer on any change, while Zustand gives you per-slice granularity for free.
The mental unlock: the store is not React state. You can read and write it from anywhere — event handlers, timers, other stores, non-React code — via store.getState() / store.setState(). React is just one subscriber. This makes Zustand equally at home for global UI state and for imperative, outside-render logic (a websocket pushing into the store, say).
⚙️ How it actually works
create(fn) calls your initialiser with set and get and returns a hook that is also a vanilla store object (getState, setState, subscribe, getInitialState). Internally it subscribes via React 18's useSyncExternalStore, which is the officially-blessed way to read an external mutable source without tearing during concurrent rendering.
setshallow-merges by default:set({ count: 1 })merges into the top level (unlikeuseState's replace). Passset(fn, true)to replace. Nested updates you do yourself, immutably.- Selectors are the performance model.
useStore(s => s.count)subscribes tocountonly.useStore()with no selector subscribes to the whole store and re-renders on any change. - Equality: the default is
Object.is. Return an object/array from a selector and every update creates a new reference → re-render. Fix withuseShallow(v4/v5) so a{ a, b }selection compares field-by-field. - Middleware are store wrappers:
persist(localStorage/IndexedDB),immer(mutable syntax),devtools(Redux DevTools),subscribeWithSelector(imperative subscriptions).
💻 Code
import { create } from 'zustand';
import { useShallow } from 'zustand/react/shallow';
interface CartState {
items: Record<string, number>;
add: (id: string) => void;
total: () => number;
}
export const useCart = create<CartState>((set, get) => ({
items: {},
// set MERGES at the top level; we rebuild `items` immutably ourselves.
add: (id) =>
set((s) => ({ items: { ...s.items, [id]: (s.items[id] ?? 0) + 1 } })),
total: () => Object.values(get().items).reduce((a, b) => a + b, 0),
}));
function Badge() {
// ✅ Subscribes to ONE derived value; re-renders only when the count changes.
const count = useCart((s) => Object.keys(s.items).length);
return <span>{count}</span>;
}
function Controls() {
// ❌ New object every render → re-renders on ANY store change.
const { add, items } = useCart((s) => ({ add: s.add, items: s.items }));
// ✅ useShallow compares the picked fields individually.
const { add: add2 } = useCart(useShallow((s) => ({ add: s.add })));
return <button onClick={() => add2('sku-1')}>Add</button>;
}
// Outside React entirely — no hook needed:
useCart.getState().add('sku-2');
⚖️ Trade-offs
- Use it when you want global/shared client state without the Redux ceremony, or you need to mutate state from outside React (websockets, event buses). It's the pragmatic default for most apps that have outgrown
useState+ prop drilling. - Don't use it for server cache — same rule as always: API data with fetching/caching/expiry belongs in React Query, not a Zustand store you hand-invalidate.
- vs Redux: Zustand drops the action/reducer indirection and the Provider. You lose the strict "every change is a named action" audit trail and enforced purity — for many teams a worthwhile trade, for a large regulated app maybe not.
- vs Jotai: Zustand is one store you select from (top-down); Jotai composes many atoms up. Zustand suits a cohesive store; Jotai suits fine-grained, independent pieces.
- No enforced structure is a double edge: freedom to shape the store however, and freedom to make a mess. Discipline is on you.
💣 Gotchas interviewers probe
- Returning a fresh object/array from a selector re-renders on every update because the default
Object.ischeck fails. This is the Zustand gotcha — reach foruseShallow, or select primitives with separate hook calls. setmerges,useStatereplaces. Candidates expecting replace semantics overwrite nothing and are surprised. For nested state you still spread manually (or add theimmermiddleware).- Selecting the whole store (
useStore()with no selector) re-renders on any change — defeats the point. Always select. - SSR / Next.js: a module-level
createstore is shared across requests on the server — a cross-request state leak. Use a per-request store created in context, or thecreateStore+ Provider pattern. - Persist hydration mismatch:
persistrehydrates asynchronously, so the first server/client render can differ. Gate onpersist.onFinishHydration/ ahasHydratedflag to avoid hydration errors. - Stale closures: capturing
get()at the wrong time.get()inside an action always reads current state — use it, don't close over a snapshot.
🎯 Say this in the interview
"Zustand is a tiny external store — basically a closure holding state plus a React hook built on
useSyncExternalStore. There's no Provider; components subscribe with a selector and re-render only when that selected slice changes, which is the granularity Context can't give you. Because the store lives outside React, I can also read and write it from event handlers or a websocket viagetState/setState, which is really handy. The two things I'm careful about: selectors that return a new object or array re-render on every update since the default equality isObject.is, so I useuseShallow; andsetmerges at the top level rather than replacing likeuseState, so nested updates I still spread myself. And I'd never put server data in it — that's a cache, it belongs in React Query."
🔗 Go deeper
- Zustand — Docs — the whole API is small; read
getting-startedand the middleware pages. - Zustand — Prevent re-renders with useShallow — the fix for the new-reference selector bug.
- React — useSyncExternalStore — the primitive Zustand rides on, and why it avoids tearing.
- Zustand — SSR and hydration — the per-request store pattern that avoids state leaks.