Topic 15.3
Zustand
In one line
Zustand is a tiny external store library: create makes a hook that holds state and actions together, components read slices with selectors and re-render only when their slice changes, and middleware adds saving to localStorage and Redux DevTools.
Think of it like this
A notice board in a hostel corridor. Anyone can walk up and read it, and the warden can update it without asking each room. Students who care about the mess menu only look at the menu section; a change to the laundry timings doesn't make them come running. A Zustand store is that board: one place outside your components, readable from anywhere, and each component watches only the section it cares about.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Zustand
- A tiny state library: one
createcall gives you a hook backed by an external store. - create
- Makes a store from a function that returns state and actions, and returns a hook to read it.
- set
- Updates the store. It merges the object you give into the state (one level deep).
- get
- Reads the current state inside an action, without subscribing.
- Action
- A function stored in the store that changes the state, like
increment. - Shallow comparison
- Comparing two objects field by field at the top level, instead of checking if they're the same object.
- useShallow
- A Zustand helper that keeps a selector's result stable when its fields haven't changed.
- Middleware
- A wrapper that adds a feature to a store, like saving it (
persist) or debugging it (devtools). - Hydration (persist)
- Loading the saved state back from storage into the store when the app starts.
Step by step
01Install and create a store
In a real project: npm install zustand. A store is one file that exports a hook. State and the actions that change it sit side by side.
import { create } from "zustand";
export const useCart = create((set) => ({
items: [],
add: (item) => set((s) => ({ items: [...s.items, item] })),
remove: (id) => set((s) => ({ items: s.items.filter((x) => x.id !== id) })),
clear: () => set({ items: [] }),
}));02Read slices with selectors
Each component selects the smallest thing it needs. The header's badge selects only the count, so removing and re-adding the same number of items doesn't re-render it.
function CartBadge() {
const count = useCart((s) => s.items.length);
return <span className="badge">{count}</span>;
}
function AddButton({ product }) {
const add = useCart((s) => s.add); // actions never change: no re-renders
return <button onClick={() => add(product)}>Add</button>;
}03Pick several fields with useShallow
When one component needs several fields, wrap the selector so the result is compared field by field.
import { useShallow } from "zustand/react/shallow";
const { items, coupon } = useCart(
useShallow((s) => ({ items: s.items, coupon: s.coupon })),
);04Add middleware
Wrap the store function. persist saves chosen fields to localStorage; devtools sends every change to the Redux DevTools extension. In TypeScript, use the curried form create<CartState>()(...) so middleware types work.
import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";
type Item = { id: string; name: string; price: number };
type CartState = {
items: Item[];
add: (item: Item) => void;
};
export const useCart = create<CartState>()(
devtools(
persist(
(set) => ({
items: [],
add: (item) => set((s) => ({ items: [...s.items, item] }), false, "cart/add"),
}),
{ name: "cart", version: 1, partialize: (s) => ({ items: s.items }) },
),
{ name: "CartStore" },
),
);05Use it outside React
useCart.getState(), useCart.setState() and useCart.subscribe() work anywhere: a socket handler can add a line, a logger can watch changes, and a test can reset the store with useCart.setState({ items: [] }).
Try it yourself
- 1
Read the whole store
In the first example, change
CountLabeltoconst { count } = useCounter();. Predict which clicks now render it. Run it: it renders on step 5 too, because without a selector it subscribes to the whole state object. - 2
Replace instead of merge
Change
setStepto(step) => set({ step }, true). Predict what happens tocountand to the actions. Run it and click Add step after picking a step: the state was replaced by{ step }, socountand every action are gone (incrementbecomesundefined). The second argument is rarely what you want. - 3
Save one more field
In the persist example, add
menuOpentopartialize. Predict the twosaved:lines. Run it: they now include"menuOpen":true. Then think about whether a learner would want the menu to reopen on every visit.
Code & diagrams
No provider anywhere. Each click re-renders only the component whose selected value changed: `CountLabel` for count, `StepPicker` for step. `IncrementButton` selects an action, which never changes, so it renders once.
Preview
Console
Nothing logged yet.Expected console output
render CountLabel
render StepPicker
render IncrementButton
click Add step
render CountLabel
click step 5
render StepPicker
click Add step
render CountLabel
get() inside an action: 6`ping` changes `lastSeen`, which `Card` doesn't select: the new `{ name, city }` object is shallow-equal to the last one, so the old object is returned and `Card` is skipped. `rename` changes a selected field, so it renders. Without `useShallow` this selector would loop forever.
Preview
Console
Nothing logged yet.Expected console output
render Card: Asha from Pune
ping (lastSeen changes)
ping again
rename
render Card: Asha K. from PuneThe store starts as `dark` because `persist` loaded the saved JSON when the store was created (localStorage loads synchronously). `partialize` keeps `menuOpen` out of storage. The preview's localStorage is in memory and resets on every Run; in a real browser the theme would survive a reload.
Preview
Console
Nothing logged yet.Expected console output
loaded theme: dark
analytics: theme -> light
saved: {"state":{"theme":"light","fontSize":18},"version":0}
saved: {"state":{"theme":"light","fontSize":20},"version":0}"use client";
import { createContext, useContext, useState, type ReactNode } from "react";
import { createStore, useStore } from "zustand";
type CounterState = { count: number; inc: () => void };
const createCounterStore = (initial: number) =>
createStore<CounterState>()((set) => ({
count: initial,
inc: () => set((s) => ({ count: s.count + 1 })),
}));
const CounterStoreContext = createContext<ReturnType<typeof createCounterStore> | null>(null);
export function CounterStoreProvider({ initial, children }: { initial: number; children: ReactNode }) {
// one store per mounted provider = one per request on the server
const [store] = useState(() => createCounterStore(initial));
return <CounterStoreContext value={store}>{children}</CounterStoreContext>;
}
export function useCounterStore<T>(selector: (s: CounterState) => T): T {
const store = useContext(CounterStoreContext);
if (!store) throw new Error("useCounterStore must be used inside CounterStoreProvider");
return useStore(store, selector);
}Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Selecting a new object without useShallow
In a Zustand 5 component, write const { count, step } = useCounter((s) => ({ count: s.count, step: s.step }));.
Break #2
Persisted data from an old app version
Version 1 of the app saved the cart as strings: { items: ["pen"] }. Version 2 expects objects and renders item.price.toFixed(2), but keeps the same name and sets no version.
Myth vs fact
Myth
Zustand needs a Provider at the top of the app like Redux.
Fact
No provider is required: the store lives in a module and any component can call its hook. You add a context only for per-request stores in server rendering or for isolated test instances.
Myth
const { a, b } = useStore() is fine because you only use a and b.
Fact
Without a selector the component subscribes to the whole state and re-renders on every change in the store, including fields it never reads.
Myth
set replaces the state like useState's setter.
Fact
set merges one level deep. Nested objects are not merged: set({ user: { name } }) replaces user entirely, so spread the old nested object yourself.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Zustand 5 (October 2024) dropped React 17 support, removed the default export and the equality-function argument from
create, and madeuseShallowthe way to select objects.persistno longer stores the initial state at creation time; it writes on the firstset. Check those when you upgrade from v4. - ▸
persistwith localStorage hydrates synchronously in the browser, but the server has no storage, so server-rendered HTML uses the initial state and the client may differ: a hydration mismatch (Topic 20.6). UseskipHydration: trueand calluseStore.persist.rehydrate()in an effect, or render storage-dependent parts only on the client. - ▸
A store can grow by slices: separate functions that each create part of the state (
createCartSlice(set, get),createUserSlice(set, get)) spread into onecreatecall. That keeps one store (cross-slice actions stay easy) without one giant file. - ▸
Actions living in the store make testing easy: call
useCart.getState().add(item)and assert onuseCart.getState()with no rendering. Reset between tests withuseCart.setState(initialState, true).
Remember this
- 1
Zustand (German for "state") is a small library (about 1 KB gzipped) built on the external store pattern from Topic 15.2. You call **
createwith a function that returns your initial state and** your actions in one object.creategives back a hook, conventionally nameduseSomethingStoreoruseSomething. There is no provider: any component can call the hook. Version 5 (current in 2026) needs React 18 or newer and uses React's ownuseSyncExternalStoreinside. - 2
The function you pass to
createreceives **setandget**.set(partial)merges the object you give into the state (one level deep, like the old class componentsetState), soset({ step: 5 })keepscount.set((state) => partial)computes the change from the current state, which you need when the new value depends on the old one.set(newState, true)replaces the whole state instead of merging.get()reads the current state inside an action, for logic that needs a value without changing it. - 3
Components read state with a selector:
const count = useCounter((s) => s.count). The component re-renders only when the selected value changes (byObject.is). Actions are functions created once with the store, so selecting an action (useCounter((s) => s.increment)) never causes a re-render. Calling the hook with no selector (const { count } = useCounter()) returns the whole state object, which changes on every update, so that component re-renders on every change in the store. Avoid it except in tiny stores. - 4
To pick several values at once, a selector that returns a new object (
(s) => ({ name: s.name, city: s.city })) causes an infinite loop in Zustand 5, because each call gives a new object. Either call the hook once per value (const name = useProfile((s) => s.name)), or wrap the selector in **useShallow** (import { useShallow } from "zustand/react/shallow", also exported fromzustand/shallow).useShallowremembers the last result and returns it again if every field is the same byObject.is: a shallow comparison (compare each top-level field, not deep inside). Zustand 4 let you passshallowas a second argument to the hook; version 5 removed that fromcreate(it lives on increateWithEqualityFnfromzustand/traditional). - 5
Middleware wraps the function you pass to
createto add features. **persist** (fromzustand/middleware) saves the state to storage (localStorage by default, as JSON shaped{ state, version }) and loads it back when the store is created. Its options:name(the storage key, required),partialize(choose which fields to save, so UI flags and functions stay out),versionplusmigrate(upgrade data saved by an older version of your app),storage(for examplecreateJSONStorage(() => sessionStorage)), andskipHydration. **devtools** connects the store to the Redux DevTools browser extension so you can see each change and travel back in time; name each change withset(partial, false, "cart/add"). Putdevtoolsoutermost:create(devtools(persist(fn, opts))). Other middleware:immer(mutate-style updates, needs theimmerpackage),subscribeWithSelector,combine. - 6
The hook is also a store object you can use outside React:
useCounter.getState()reads,useCounter.setState(partial)updates, anduseCounter.subscribe((state, prev) => ...)listens (it returns an unsubscribe function). That makes Zustand handy for WebSocket handlers, analytics, tests and non-React code. For server rendering (Topic 20.1), a module-level store is shared by every request on the server, so frameworks like Next.js use **createStore** (fromzustand/vanillaorzustand) to make one store per request, put it in context, and read it withuseStore(store, selector).
Explain it without notes
How does Zustand decide which components re-render after a set?
What is the difference between set(partial), set(fn) and set(state, true)?
Why does Zustand 5 need useShallow for selectors that return objects?
How would you use a Zustand store safely with server rendering in Next.js?
Practice
Write a Zustand useTodos store with todos, add(text) and toggle(id), and a component that shows how many todos are done, re-rendering only when that number changes.
Add persist to useTodos so the list survives reloads under the key todos, saving only the todos array.
Trade-offs
- ↔
Zustand is tiny and has almost no boilerplate, but it gives you little structure: no enforced action log, no conventions for where logic lives. Large teams sometimes add their own rules (slices, naming actions for devtools) or choose Redux Toolkit for its conventions.
- ↔
Module-level stores are convenient in single-page apps but are shared singletons: per-request stores in context are needed for server rendering, and tests must reset the store between cases.
Done when you can
I can create a Zustand store with state and actions.
I read slices with selectors and never call the hook without one in big stores.
I can select several fields with
useShallow.I can persist chosen fields with
persist,partializeandversion.I can read, update and subscribe to a store outside React.
I know why server rendering needs a store per request.