Zustand
Zustand is a minimal client-state store: a hook created by create(), no provider, no boilerplate, and the store lives outside React so you can read/write it anywhere. You subscribe to slices with selectors, and a component re-renders only when its selected slice changes. It’s the popular middle ground between “just useState/Context” and “full Redux.”
What does a basic Zustand store look like?
import { create } from "zustand";
const useStore = create<{ count: number; inc: () => void }>((set) => ({
count: 0,
inc: () => set((s) => ({ count: s.count + 1 })),
}));
function Counter() {
// subscribe to just `count`
const count = useStore((s) => s.count);
const inc = useStore((s) => s.inc);
return <button onClick={inc}>{count}</button>;
}No <Provider>, no reducers, no action types. set does a shallow merge by default.
How does Zustand control re-renders?
Via the selector. useStore((s) => s.count) re-renders only when count’s reference changes. Selecting the whole store (useStore()) re-renders on any change — avoid it. For selecting multiple fields, return a stable shape and pass an equality fn:
import { useShallow } from "zustand/react/shallow";
const { a, b } = useStore(useShallow((s) => ({ a: s.a, b: s.b })));Without useShallow, returning a fresh object each render defeats memoization and re-renders every time. This is the #1 Zustand gotcha.
How do you scale a store — the slices pattern?
Compose multiple slice creators into one store:
const createCartSlice = (set) => ({ items: [], add: (i) => set((s) => ({ items: [...s.items, i] })) });
const createUiSlice = (set) => ({ modalOpen: false, toggle: () => set((s) => ({ modalOpen: !s.modalOpen })) });
const useStore = create((...a) => ({ ...createCartSlice(...a), ...createUiSlice(...a) }));Keeps a single store but organizes by feature — the Zustand answer to Redux’s combineReducers.
What middleware matters?
persist— save tolocalStorage/AsyncStoragewith versioning + migrations.immer— write “mutating” updates safely (set((s) => { s.items.push(x) })).devtools— Redux DevTools integration (time-travel, action log).subscribeWithSelector— fine-grainedsubscribe.
const useStore = create(persist(immer((set) => ({ /* ... */ })), { name: "cart", version: 1 }));How do you use the store outside React?
The created hook carries static methods — useful in event handlers, sagas, tests, or non-React code:
useStore.getState().inc(); // read/call without a component
useStore.setState({ count: 0 }); // write directly
const unsub = useStore.subscribe((s) => console.log(s.count));This is a real advantage over Context, which is React-only.
What are “transient updates” and why use them?
For high-frequency values (mouse position, scroll) you can subscribe imperatively and write to a ref instead of triggering re-renders:
useEffect(() => useStore.subscribe((s) => { boxRef.current.style.transform = `translateX(${s.x}px)`; }), []);The component never re-renders; you mutate the DOM directly. Avoids render storms.
Zustand vs Redux?
Zustand: less boilerplate, no provider, store callable outside React, tiny bundle. Redux/RTK: stronger conventions, a richer middleware/devtools ecosystem, structured large-team flows, and RTK Query for server state. For most apps that only need some shared client state, Zustand wins on simplicity; Redux earns its weight when client state is large and the team needs enforced structure.
Gotchas
- Returning a new object/array from a selector without
useShallowre-renders every time — selectors must return stable references or use shallow equality. - SSR / Next.js: a module-level store is shared across requests on the server — leaking one user’s state to another. Create the store per request (e.g., via a context-provided factory) for SSR.
setshallow-merges top level only — nested updates need spreads or theimmermiddleware.persisthydration is async-ish — guard against rendering with pre-hydration state (use theonRehydrateStorage/hasHydratedflag) to avoid hydration mismatches.- It’s client state, not server state — don’t fetch-and-store API data in Zustand; use a query library (Server State vs Client State).
Related
Further reading
- Zustand docs: https://zustand.docs.pmnd.rs/
- Zustand — Preventing rerenders with
useShallow: https://github.com/pmndrs/zustand
Interview angle 5
- “What is Zustand’s model?” - a small store living outside React, with components subscribing through selectors. The selector decides what re-renders, so a component that selects one field ignores every other change to the store.
- “What is the classic Zustand re-render bug?” - a selector returning a fresh
object or array each call, so the reference always differs and the component
re-renders on every store update.
useShallowaround the selector compares the contents instead of the reference. - “How do you organise a large store?” - the slices pattern: one store
composed from per-feature slice creators, so features stay separable without
the overhead of several stores.
persist,immeranddevtoolsmiddleware cover most of what you would otherwise write. - “What can you do outside React?” -
getState,setStateandsubscribe. That makes the store usable from event handlers, workers and plain modules, and a transient subscription lets you react to a high-frequency value - a cursor position, a scroll offset - without re-rendering at all. - “What breaks under SSR?” - a module-scope store shared across requests, so one user’s state is visible to the next. Create the store per request and pass it through a provider on the server; a single shared instance is only safe in the browser.