Zustand
Zustand is a tiny hook-based store with no provider and no action objects. Update state with set, and see selective subscriptions re-render only the components whose selected slice changed.
🎯 What you'll learn
- A minimal hook-based store without a provider
- set: direct updates
- Selective subscriptions
- Multiple stores, middleware (persist, devtools) and internals
📚 Before you start
1A Minimal, Hook-Based Store — No Provider Required
Zustand is a small state-management library built around one core idea: a store is just a hook you call. There's no <Provider> wrapping your app, no action-type constants, no reducer switch statements — you define a store with create(), and any component imports and calls the resulting hook directly, exactly like useState, except the state lives outside React and is shared across every component that calls the hook.
import { create } from "zustand";
interface CounterStore {
count: number;
increment: () => void;
reset: () => void;
}
const useCounterStore = create<CounterStore>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
reset: () => set({ count: 0 }),
}));
function Counter() {
const count = useCounterStore((state) => state.count);
const increment = useCounterStore((state) => state.increment);
return <button onClick={increment}>{count}</button>;
}Contrast with Redux (previous two topics): no store needs to be threaded through a <Provider> at the app root, no dispatch({ type: ... }) ceremony — increment is a plain function living right next to the state it updates, called directly.
2set: Direct State Updates, No Action Objects
const useStore = create<{ user: string | null; setUser: (name: string) => void; logout: () => void }>(
(set, get) => ({
user: null,
setUser: (name) => set({ user: name }), // shallow-merges { user: name } into state
logout: () => set({ user: null }),
greet: () => `Hello, ${get().user}`, // get() reads current state outside a component
})
);set(partialState) shallow-merges the given object into the existing state (like this.setState in old-style class components) — you only specify the keys that changed, everything else is preserved automatically. set((state) => partialState) (the functional form, used in section 1's increment) is used whenever the update depends on the current value, avoiding stale-closure bugs the same way useState's updater-function form does. get() reads the current state from inside a store action, useful when one action's logic needs to read other pieces of state without waiting for a re-render.
3Selective Subscriptions: the Real Point of the Hook Argument
// Selects ONLY `count` — this component re-renders ONLY when count changes,
// never when unrelated store fields (like `user` in a combined store) change
const count = useCounterStore((state) => state.count);
// Selecting the WHOLE state object (no selector) re-renders on ANY change —
// almost always wrong for a store with more than one independent field
const wholeState = useCounterStore(); // avoid this pattern for multi-field storesEvery useStore(selector) call subscribes that component to exactly the slice selector returns, using reference/shallow equality to decide whether to re-render — passing no selector (or an identity selector returning the whole state) subscribes to every change, defeating the fine-grained re-render control that's the actual reason to pass a selector function at all. This is the same underlying concern as Redux's useSelector from the previous topics, expressed through a plain function argument instead of a separate hook.
4Multiple Independent Stores, or One Combined Store
// Option A: separate stores per concern — each is its own hook
const useAuthStore = create<AuthState>(...);
const useCartStore = create<CartState>(...);
// Option B: one store, multiple logical "slices" combined manually
const useAppStore = create<AuthState & CartState>((set, get) => ({
...createAuthSlice(set, get),
...createCartSlice(set, get),
}));Unlike Redux, which pushes toward one store with combineReducers-style slices, Zustand has no strong opinion here — many small independent stores (Option A) are just as idiomatic as one larger combined store (Option B), since there's no single-Provider constraint forcing consolidation. Teams pick based on how often different pieces of state actually change together.
5Middleware: persist, devtools, and Friends
import { create } from "zustand";
import { persist, devtools } from "zustand/middleware";
const useSettingsStore = create<SettingsState>()(
devtools(
persist(
(set) => ({
theme: "light",
setTheme: (theme) => set({ theme }),
}),
{ name: "settings-storage" } // localStorage key
)
)
);Zustand supports optional middleware wrapping the store creator function — persist automatically syncs store state to localStorage (or another storage adapter) and rehydrates it on load, devtools wires the store into Redux DevTools for the same time-travel debugging experience covered in the Redux topics, applied here to a store with no Redux underneath it at all.
Under the HoodHow it works internally▾
Zustand's store is a plain external object with a subscribe/publish mechanism — React's involvement is limited to one small hook wrapping that subscription. create(initializer) builds a store object ({ getState, setState, subscribe }, conceptually similar to the plain-Redux store from the previous topics but without any reducer/action-type machinery) that lives entirely outside React's render tree, in a plain JS closure. The useStore hook it returns internally calls useSyncExternalStore (React's built-in primitive for subscribing a component to state that lives outside React, also what react-redux's modern useSelector is built on) with your selector function — React re-renders the component if the selector's result changes according to a comparison function (Object.is by default), and does nothing otherwise. There's no Context, no Provider tree walk, no reconciliation-triggered re-computation; it's a direct subscription from the component straight to the external store object.
set's shallow merge is a plain Object.assign-style spread, not Immer-powered draft mutation. Unlike Redux Toolkit's createSlice (previous topic), calling set({ count: 5 }) does not track deep mutations through a Proxy — it computes { ...currentState, ...partialState } (or, for the functional form, { ...currentState, ...updaterFn(currentState) }) and republishes that as the new state, notifying every subscribed selector to re-check. This is exactly why nested object updates in Zustand still require the same explicit-spread discipline that plain Redux reducers needed before Immer — set({ user: { ...state.user, name: "New" } }), not set((state) => { state.user.name = "New"; }), unless you separately opt into Zustand's own Immer-integration middleware.
⚠️ Common Pitfalls
- Selecting the whole store state with no selector function (
useStore()instead ofuseStore((s) => s.count)) — subscribes the component to every field's changes, causing unnecessary re-renders exactly like skipping a ReduxuseSelector's narrowing. - Mutating nested state directly inside
set's updater without spreading, expecting Immer-like safety — plain Zustand does a shallow merge only; deep nested updates still need explicit spreading (or the separate Immer middleware). - Creating a new store inside a component body (
const useStore = create(...)written directly inside a component function) instead of at module scope — creates a brand-new, disconnected store on every render instead of one shared store all components subscribe to. - Returning a brand-new object/array from a selector on every call (
useStore((s) => ({ a: s.a, b: s.b }))) without a shallow-equality comparator — the defaultObject.ischeck sees a new object reference every time and re-renders on every store change regardless of whethera/bactually changed; Zustand exports ashallowcomparator for exactly this case. - Assuming
persistmiddleware synchronizes across browser tabs automatically — by default it only writes tolocalStorageand rehydrates on load; live cross-tab sync requires additional wiring (listening forstorageevents), not somethingpersistdoes on its own.
📋 Quick Reference
create<T>((set, get) => ({ ...state, ...actions })) → returns a useStore hook, no Provider needed
set(partialState) → shallow-merges into state
set((state) => partialState) → functional form, avoids stale-closure reads
get() → reads current state from inside a store action
useStore((state) => state.slice) → selective subscription, re-renders only when that slice changes
useStore() → subscribes to EVERYTHING — usually wrong for multi-field stores
persist(initializer, { name }) → middleware: syncs state to localStorage, rehydrates on load
devtools(initializer) → middleware: wires into Redux DevTools
Mechanism: plain external object + useSyncExternalStore, no Context/Provider tree involved
set() does a shallow merge (Object.assign-style) — NOT Immer draft mutation, unlike RTK's createSlice
Contrast: Redux/RTK = one store, actions/reducers, Provider-based, strong single-store convention
Zustand = any number of stores, direct functions, no Provider, lighter ceremony🧠 Key takeaways
create((set, get) => ({ ... }))returns a hook; no Provider needed.setshallow-merges partial state; actions are just functions in the store.- Components subscribe with selectors; only changed selections re-render (Object.is).
- Select narrowly, use
useShallowfor object selections; addpersist/devtoolsmiddleware as needed.
🎤 Interview Q&A
Q1What is Zustand and why is it popular?core▾
A minimal state management library: create a store with a hook (create((set) => ({...}))), no providers or boilerplate, selective subscriptions to avoid re-renders, and middleware for persistence and devtools.
- Does it need a Provider? No, stores are module-level hooks (context can be used for per-instance stores).
Q2How do selectors prevent re-renders?core▾
Components subscribe to a slice: useStore(s => s.count) re-renders only when that value changes. Selecting objects needs useShallow to compare shallowly.
- Common mistake?
useStore()without a selector subscribes to everything.
Q3How do you handle async actions?core▾
Define async functions in the store that call set when data arrives; or keep server state in TanStack Query and use Zustand only for client state.
- Access state outside React?
useStore.getState()andsubscribe.
Q4What middleware does Zustand offer?senior▾
persist (localStorage/sessionStorage), devtools (Redux DevTools), immer (mutable-style updates), and subscribeWithSelector.
- SSR caveat? Module-level stores are shared across requests on the server; create per-request stores.
Q5Zustand vs Redux Toolkit vs Context?core▾
Context: simple, low-frequency values. Zustand: lightweight global client state with good performance and little code. Redux Toolkit: structured patterns, middleware ecosystem and tooling for large teams.
- How would you choose? Team size, complexity, and need for conventions and devtools.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.
✅ Check yourself
Answer the questions — feedback is instant.