Zustand basics: create, selectors, set, persist
- Create a store with
create<T>()(…)and use getState/setState outside React, including resets between tests - Predict what a selector does in Zustand v5: a primitive re-renders only when it changes, a fresh object loops unless wrapped in useShallow
- Predict the one-level merge in set() and in persist's rehydration, and use functional set when an update builds on the current state
- Configure persist with name and partialize, and keep persisted values out of the hydration render under SSR
- Write a store hook on useSyncExternalStore that re-renders only when the selected slice changes
Read
create() makes a hook; the selector decides who re-renders
FlagA Zustand store is one call. create returns a hook that any component can import —
no provider, no wrapper. This unit is about Zustand v5 (this app runs 5.0.x); where v4
behaved differently, it says so.
import { create } from "zustand";
type CounterState = {
count: number;
inc: () => void;
reset: () => void;
};
export const useCounter = create<CounterState>()((set) => ({
count: 0,
inc: () => set((s) => ({ count: s.count + 1 })),
reset: () => set({ count: 0 }),
}));
The empty () after create<CounterState> isn't a typo: it's the curried form Zustand's
TypeScript guide recommends whenever you annotate the state type (the medium tier explains
why).
Note that actions live in the store, next to the data they change. Components call
inc(); they never reach in and assign count.
Reading it, always with a selector:
const count = useCounter((s) => s.count); // ✅ re-renders only when count changes
const { count } = useCounter(); // ⚠️ no selector = the whole state: re-renders on ANY change
const inc = useCounter((s) => s.inc); // ✅ a function identity that never changes
That selector is the whole difference from Context: the component subscribes to the
value it selected, compared with Object.is. Change some unrelated field and this
component doesn't re-render at all.
One rule to carry into the next tier: a selector should return something that stays
Object.is-equal while nothing it reads has changed — a field, not a freshly built object.
Selector identity, and persisting a store
FlagThe selector rule (Zustand v5)
The hook create returns is React's useSyncExternalStore with
() => selector(getState()) as its snapshot. React compares snapshots with Object.is and
treats any difference as a change. So a selector that builds a new object or array on every
call never compares equal — not even when nothing in the store moved — and React keeps
re-rendering until it gives up with "Maximum update depth exceeded" (after warning, in
development, that "the result of getSnapshot should be cached").
// ❌ a new object on every call → an infinite render loop in v5
const { count, name } = useStore((s) => ({ count: s.count, name: s.name }));
// ✅ two atomic selectors, each a primitive
const count = useStore((s) => s.count);
const name = useStore((s) => s.name);
// ✅ or one selector wrapped in useShallow
import { useShallow } from "zustand/react/shallow";
const { count, name } = useStore(useShallow((s) => ({ count: s.count, name: s.name })));
useShallow remembers the previous result and hands it back while the new one is
shallow-equal, so the snapshot only changes when a field does. Prefer atomic selectors;
reach for useShallow when you genuinely need a derived object or array (a filtered list,
Object.keys(s)). The same trap catches an inline fallback: s.onSave ?? (() => {}) is a
new function on every call — hoist the fallback to a module-level constant.
v4 was different. There, the same object selector didn't loop; it re-rendered the
component on every store change. v5 dropped the per-hook equality function, so v4 code that
passed shallow as the hook's second argument needs useShallow (or createWithEqualityFn
from zustand/traditional, which keeps the v4 behaviour).
persist: localStorage in one line
import { create } from "zustand";
import { persist } from "zustand/middleware";
export const useSettings = create<Settings>()(
persist(
(set) => ({
theme: "system",
setTheme: (theme) => set({ theme }),
}),
{
name: "whetstone-settings", // the storage key
partialize: (s) => ({ theme: s.theme }), // persist only the durable state
version: 1,
migrate: (persisted, from) => (from === 0 ? upgrade(persisted) : persisted),
},
),
);
Note create<Settings>()( … ). Zustand's TypeScript guide recommends this curried form
whenever you annotate the state type: TypeScript can't infer some type arguments while you
pass others, and the extra call works around that. Without middleware,
create<Settings>(…) happens to compile; with persist wrapping the initializer it
doesn't — so write the curried form every time.
Details that bite:
partialize— chooses which state is durable. You don't need it to strip actions (JSON serialization drops function values, and rehydration shallow-merges over the initializer, so your actions come back intact regardless). You need it to keep ephemeral, derived, or sensitive fields out oflocalStorage: a smaller payload, nothing private on disk, and less shape for a futuremigrateto worry about.version+migrate— the shape you persist today will change. When the stored version doesn't match the code's and there's nomigrate, persist ignores the stored value rather than loading a shape your code no longer expects.- Rehydration merges one level deep, like
set. The stored state is spread over the initial state ({ ...current, ...persisted }), so a nested object comes back wholesale from storage. Add a field inside it later (prefs.fontSize) and every returning user's savedprefslacks it. Pass a deepmergeoption, or bumpversionand fill the gap inmigrate. - Hydration happens at creation. With synchronous storage like
localStorage(the default), the store is already hydrated whencreatereturns — before React renders anything — anduseSettings.persist.hasHydrated()is alreadytrue. Only async storage (IndexedDB, React Native's AsyncStorage) hydrates later, in a microtask; that's whenhasHydrated()andonFinishHydrationare useful. - SSR: the server has no
localStorage, so it renders the initial state, and the first client render must match it. Because the client store is already hydrated,hasHydrated()can't be your gate (and on the server, with no storage,persistdoesn't attachuseSettings.persistat all, so calling it in render throws there). Show persisted values only after mount — this app'suseMounted()(lib/use-mounted.ts, used withlib/stores/interests-store.ts) isfalseduring hydration andtrueafter — or create the store withskipHydration: trueand calluseSettings.persist.rehydrate()in an effect.
What create() is actually doing — and testing a singleton
FlagZustand in ~30 lines
There's no magic under create: a variable, a Set of listeners, and React's
useSyncExternalStore (added in React 18, which is v5's minimum).
type Listener = () => void;
type Store<T> = {
getState: () => T;
getInitialState: () => T;
subscribe: (listener: Listener) => () => void;
};
function createStore<T>(init: (set: (partial: Partial<T>) => void) => T): Store<T> {
let state: T;
const listeners = new Set<Listener>();
const set = (partial: Partial<T>) => {
state = { ...state, ...partial }; // the one-level merge
listeners.forEach((l) => l()); // notify every subscriber before set returns
};
state = init(set);
const initialState = state;
return {
getState: () => state,
getInitialState: () => initialState,
subscribe: (l) => {
listeners.add(l);
return () => listeners.delete(l);
},
};
}
// The React binding: subscribe to the store, re-render only when the SELECTED value changes.
function useStore<T, U>(store: Store<T>, selector: (s: T) => U): U {
return useSyncExternalStore(
store.subscribe,
() => selector(store.getState()), // client snapshot
() => selector(store.getInitialState()), // server snapshot: SSR and the hydration render
);
}
This is close to the real thing. In Zustand v5, the hook create returns calls React's
useSyncExternalStore with exactly these two snapshot functions (wrapped in useCallback);
the real setState adds the function form and the replace flag, and skips notifying when
it's handed the current state object itself (set((s) => s)). Everything else Zustand ships —
persist, devtools, immer, subscribeWithSelector — is middleware: a function around
your initializer that swaps in its own set or patches the store's API (persist writes
to storage on every set; subscribeWithSelector replaces subscribe).
Two behaviours fall straight out of useSyncExternalStore:
- Selector identity. React re-renders whenever the snapshot differs by
Object.isfrom the last one it saw, and it re-reads the snapshot after rendering to check. A selector that returns a new object is different every time, so the component never settles: "Maximum update depth exceeded". An atomic selector returns the same primitive until the field changes. - Tearing. Every component reading the store during one render sees the same snapshot,
even under concurrent rendering — something a hand-rolled
useState+useEffectsubscription doesn't guarantee.
v4 worked differently. Its hook used useSyncExternalStoreWithSelector from the
use-sync-external-store shim, which memoizes the selection and accepts an equalityFn.
That's why an object selector "only" re-rendered on every store change in v4 instead of
looping. In v5 that path survives only as createWithEqualityFn in zustand/traditional.
Testing a module-level store
The store is a singleton created at import time, so state leaks between tests. Reset it explicitly:
beforeEach(() => {
useCounter.setState(useCounter.getInitialState(), true); // `true` = replace, not merge
});
With many stores, Zustand's testing guide automates this: a shared __mocks__/zustand.ts
wraps create, records each store's initial state as it's created, and resets every store
after each test. Jest picks up a root __mocks__ folder for a package automatically; Vitest
needs vi.mock("zustand") in a setup file.
Test the store itself without React (useCounter.getState().inc() then assert on
getState().count) — plain functions, no renderer. Save component tests for the wiring.
When Zustand is the wrong answer
- The value is server data → a query cache owns it.
- The value belongs in the URL → the URL owns it.
- Only one screen uses it → local state, or a store created per screen and provided through Context so it dies with the screen (module-level stores are app-lifetime by definition).
- You need time-travel debugging and strict action logs across a large team → Redux Toolkit's ceremony starts paying for itself.
Quiz
What does const useCounter = create<CounterState>()((set) => ({ … })) give you?
In Zustand v5 a component needs both count and name. Which call re-renders it only when one of them changes?
State is { filters: { status: "open", page: 2 } } and you call set({ filters: { status: "closed" } }). What is filters afterwards?
When does set({ count: count + 1 }) lose an update that set((s) => ({ count: s.count + 1 })) would not?
Why does a persisted store usually need partialize?
A Next.js page renders useSettings.persist?.hasHydrated() ? useSettings.getState().theme : "system" from a store persisted to localStorage (the ?. is needed: on the server there is no storage, so persist never attaches useSettings.persist). The server rendered system; the saved theme is dark. What happens on the client's first render?
Two tests in one file interfere: the second starts with the count the first left behind. What's the fix?
FlagFill in the blank
Create a counter store whose increment reads the previous value.
Flagexport const useCounter = <CounterState>()((set) => ({ count: 0, inc: () => ((s) => ({ count: s.count + 1 })), }));
Subscribe to just the count, so unrelated store changes don't re-render this component.
Flagconst count = useCounter((s) => s.);
Read and write the store from plain, non-React code (a test, an event handler in a module).
FlaguseCounter.().inc(); useCounter.({ count: 0 });
Persist only the theme under a named storage key.
Flagcreate<Settings>()( ( (set) => ({ theme: "system", setTheme: (t) => set({ theme: t }) }), { name: "whetstone-settings", : (s) => ({ theme: s.theme }) }, ), );
Predict the output
const useStore = create((set) => ({ count: 0, name: "Ana", setName: (name) => set({ name }) }));
function CountLabel() {
const { count } = useStore((s) => ({ count: s.count }));
console.log("CountLabel render");
return <span>{count}</span>;
}// In the browser. Last month's release saved this under "settings":
// { "state": { "prefs": { "theme": "dark" } }, "version": 0 }
// This release adds a nested default:
const useSettings = create(
persist(() => ({ prefs: { theme: "system", fontSize: 16 } }), { name: "settings" }),
);
console.log(useSettings.getState().prefs);const useCart = create(() => ({ count: 0 }));
async function addOne() {
const { count } = useCart.getState();
await saveToServer(); // any await: a fetch, a timer
useCart.setState({ count: count + 1 });
}
// count is 0. A double-click starts two addOne() calls that overlap.
await Promise.all([addOne(), addOne()]);
console.log(useCart.getState().count);Challenge
Make the store hook subscribe per selector
Flagcounter-store.ts is a store outside React — a value, a Set of listeners, and a set
that shallow-merges and notifies. That's what Zustand's create builds for you.
The React binding is the interesting part, and it's currently wrong: useStore copies the
whole state into local state on every notification, so OtherLabel re-renders when
count changes even though it only reads other. That's the Context behaviour a store is
supposed to fix. It also subscribes in an effect, so a store change made while the
component is mounting (by a sibling's layout effect, say) never reaches it.
Your task
Edit useStore.ts so the tests pass:
useStore(selector)returns the selected slice of the current state, including a change made while the component was mounting- Changing a slice re-renders the components that select it
- Changing a slice does not re-render a component selecting a different slice
- The subscription is removed on unmount