This guide is the operational and architectural reference for Zustand: how it works internally, how to embed it in larger systems, how to extend it with custom middleware, how to ship it in SSR-heavy frameworks, and how to contribute to the project. End-user documentation lives in the User Guide.
1. Architecture Overview
Zustand is a tiny external-store primitive built around three ideas:
- A closure for state.
stateandlistenersare private variables in a closure produced by a singlecreateStoreImplfactory. There is no class, no proxy, and no global registry. - A
Setof listeners. Subscribers are registered into a JavaScriptSet.setStateiterates the set in insertion order and calls each listener with(nextState, prevState). useSyncExternalStoreas the React glue. The React adapter does not own state — it asks the vanilla store for snapshots and uses React 18's own concurrent-safe subscription primitive to render.
This layering is why the whole package is roughly 1.1KB minified+gzipped. Everything else — middleware, persistence, DevTools — is opt-in.
┌──────────────────────────────┐
│ consumer │
│ (React component / Node JS) │
└──────────────┬───────────────┘
│ useStore(api, sel) | api.subscribe()
▼
┌───────────────────────────────────────────────────────────────┐
│ src/react.ts │ src/traditional.ts │
│ useStore + create │ useStoreWithEqualityFn │
│ useSyncExternalStore │ + useSyncExternalStore- │
│ │ WithSelector │
└────────────────┬────────────────┴────────────────┬────────────┘
│ │
▼ ▼
┌────────────────────────────────────────────────┐
│ src/vanilla.ts (createStoreImpl) │
│ state (closure) │ listeners: Set<Listener> │
│ setState │ notifies on Object.is diff│
│ getState │ │
│ getInitialState │ │
│ subscribe │ returns unsubscribe fn │
└────────────────────────┬───────────────────────┘
│ wrapped (optional) by:
┌────────────────────────┴──────────────────────────┐
│ src/middleware/*.ts │
│ persist | devtools | immer | redux | combine │
│ subscribeWithSelector | ssrSafe (unstable) │
└───────────────────────────────────────────────────┘
1.1 Component inventory
| Component | Path | Role |
|---|---|---|
| Vanilla store factory | src/vanilla.ts |
The createStoreImpl core |
| React adapter | src/react.ts |
create, useStore, UseBoundStore |
| Traditional adapter | src/traditional.ts |
createWithEqualityFn, useStoreWithEqualityFn |
| Shallow comparison | src/vanilla/shallow.ts |
shallow(a, b) for objects/arrays/Map/Set |
| React shallow hook | src/react/shallow.ts |
useShallow(selector) |
| Public middleware barrel | src/middleware.ts |
Re-exports for consumers |
| Persist middleware | src/middleware/persist.ts |
Sync/async storage adapter, hydration, migration |
| Devtools middleware | src/middleware/devtools.ts |
Redux DevTools wire protocol |
| Immer middleware | src/middleware/immer.ts |
produce-based mutable updates |
| Redux middleware | src/middleware/redux.ts |
dispatch(action) + reducer |
| Combine middleware | src/middleware/combine.ts |
Merge initial state with action creators |
| subscribeWithSelector | src/middleware/subscribeWithSelector.ts |
Selective subscribe overload |
| SSR-safe (unstable) | src/middleware/ssrSafe.ts |
Throw on set when no window |
| Top-level barrel | src/index.ts |
export * from './vanilla.ts' / './react.ts' |
| Top-level shallow | src/shallow.ts |
Re-exports shallow and useShallow |
1.2 Technology stack
| Layer | Technology | Why |
|---|---|---|
| Language | TypeScript 5.9 | Strong inference, mutator types |
| Runtime peer | React 18+ | useSyncExternalStore is React 18 |
| External store shim | use-sync-external-store@^1.6.0 |
Backports the same hook for older React (used in traditional.ts) |
| Optional immer peer | immer@^11.1 (peer floor 9.0.6) |
Drafts and produce |
| Module formats | ESM (.mjs) and CJS |
Multiple condition-driven exports in package.json |
| Build | Rollup 4 + esbuild plugin | Per-entrypoint bundle (vanilla, react, middleware, immer, shallow) |
| Tests | Vitest 4 + JSDOM 27 + @testing-library/react 16 | Unit tests, type tests, DOM tests |
| Lint | ESLint 9 (flat config) + typescript-eslint | eslint.config.mjs |
| Format | Prettier 3 (semi: false, single quotes) | Project-level prettier config in package.json |
| Package manager | pnpm 10.18.3 (workspaces) | pnpm-workspace.yaml |
Note
The
package.jsontypeiscommonjs, but the published shape is dual: theexportsfield mapsimportto./esm/*.mjsanddefaultto./*.js, with areact-nativecondition that resolves before either. TypeScript consumers on TS < 4.5 get a single fallback declaration that hard-errors at compile time (ts_version_4.5_and_above_is_required.d.ts).
1.3 Data flow: a single setState
When a component triggers an action, the lifecycle is:
Component handler
└─▶ useBoundStore.getState().myAction()
└─▶ set(partial | (state) => partial, replace?)
├─ resolves nextState (calls partial(state) if a function)
├─ Object.is(nextState, state) → if equal, EXIT (no notify)
├─ if (replace OR nextState non-object) state = nextState
│ else state = Object.assign({}, state, nextState)
└─ for each listener of listeners: listener(state, prevState)
├─ React subscribers: enqueue render via useSyncExternalStore
└─ Vanilla subscribers: invoked synchronously
Note
The
Object.ischeck insetStateis the single most important performance feature. It means settingcount: 5whencountis already5is a free no-op — listeners are never invoked and React never re-renders.
2. System Requirements
2.1 Build/runtime matrix
| Environment | Required version | Notes |
|---|---|---|
| Node.js (engines) | >=12.20.0 |
Per package.json engines.node |
| TypeScript | >=4.5 |
Per typesVersions mapping |
| React (peer) | >=18.0.0 |
Optional peer |
@types/react (peer) |
>=18.0.0 |
Optional peer |
immer (peer) |
>=9.0.6 |
Optional, only for zustand/middleware/immer |
use-sync-external-store (peer) |
>=1.2.0 |
Optional, only for zustand/traditional (and the React 17 shim path) |
2.2 Browser/runtime targets
The published bundles are pure ES2017+ JavaScript with no transformation beyond what Rollup performs. They run in:
- All evergreen browsers
- Node.js 12.20+ for vanilla; Node 18+ recommended in practice
- React Native (resolved via the
react-nativeexports condition) - Edge runtimes (Cloudflare Workers, Vercel Edge, Deno)
- Service Workers — anything that supports JavaScript modules
Important
import.meta.env?.MODEis read by the devtools middleware. Bundlers that don't defineimport.meta.envwill seeundefinedand the optional-chain protects most cases — but Metro/Expo had a regression here (issue #3437, closed). When shipping to non-Vite environments, always passenabledexplicitly todevtools().
3. Configuration Reference
Zustand has no global configuration. All knobs are arguments passed into
create, createStore, or individual middleware. This section enumerates
them all.
3.1 create and createStore
| Argument | Type | Default | Description |
|---|---|---|---|
initializer |
StateCreator<T, [], Mos> |
required | (set, get, api) => state |
defaultEqualityFn (createWithEqualityFn only) |
<U>(a: U, b: U) => boolean |
Object.is |
Equality used by every selector unless overridden |
The curried form create<T>()(initializer) exists purely for TypeScript:
the empty call lets TS infer the mutator chain in two steps instead of one.
3.2 setState(partial, replace?)
| Parameter | Type | Default | Description |
|---|---|---|---|
partial |
T | Partial<T> | (state: T) => T | Partial<T> |
— | Next state or producer fn |
replace |
boolean | undefined |
implicit | If true, replace; if false, shallow-merge; if undefined and nextState is a non-object/null, replace; otherwise merge |
3.3 subscribe(listener) (vanilla)
| Parameter | Type | Description |
|---|---|---|
listener |
(state: T, prevState: T) => void |
Called on every change where Object.is(next, prev) === false |
Returns: () => void (unsubscribe). Internally listeners.add then
listeners.delete — no leaks.
3.4 subscribeWithSelector overloaded subscribe
| Parameter | Type | Default | Description |
|---|---|---|---|
selector |
(state: T) => U |
— | Selects the slice |
listener |
(slice: U, prevSlice: U) => void |
— | Fired when equalityFn(prev, next) === false |
options.equalityFn |
(a: U, b: U) => boolean |
Object.is |
Bail-out check |
options.fireImmediately |
boolean |
false |
If true, listener is called synchronously with the initial slice |
3.5 persist(initializer, options)
| Option | Type | Default | Required? |
|---|---|---|---|
name |
string |
— | yes — storage key |
storage |
PersistStorage<S, R> | undefined |
createJSONStorage(() => window.localStorage) |
no |
partialize |
(state: S) => PersistedState |
identity | no |
onRehydrateStorage |
(state: S) => ((s?: S, err?: unknown) => void) | void |
none | no |
version |
number |
0 |
no |
migrate |
(persistedState: unknown, version: number) => PersistedState | Promise<PersistedState> |
none | no — required only when version mismatches |
merge |
(persistedState: unknown, currentState: S) => S |
shallow merge | no |
skipHydration |
boolean |
false |
no |
The middleware also augments api with a persist object:
api.persist.setOptions(partial)
api.persist.clearStorage()
api.persist.rehydrate(): Promise<void> | void
api.persist.hasHydrated(): boolean
api.persist.onHydrate(cb): unsubscribe // before hydration
api.persist.onFinishHydration(cb): unsubscribe // after success/error
api.persist.getOptions(): Partial<PersistOptions<...>>
3.6 devtools(initializer, options)
| Option | Type | Default | Notes |
|---|---|---|---|
name |
string |
undefined | Connection name shown in the DevTools panel |
enabled |
boolean |
import.meta.env?.MODE !== 'production' |
If false or extension absent, middleware no-ops |
anonymousActionType |
string |
'anonymous' |
Label when set() is called without a 3rd-arg action name |
store |
string |
undefined | Group key when stacking multiple stores under one connection name |
| (passthrough) | Config |
— | Any other option from @redux-devtools/extension's connect() is forwarded |
The middleware also augments api with api.devtools.cleanup() for
disconnecting from the extension.
3.7 immer(initializer)
No options. The middleware wraps set so that function-form updaters are
passed through immer.produce. Drafts work for plain objects, Maps, and
Sets (subject to immer's own enableMapSet configuration in your app
bootstrap).
3.8 redux(reducer, initialState)
| Parameter | Type | Description |
|---|---|---|
reducer |
(state: T, action: A) => T |
Pure reducer |
initialState |
T |
Seed |
The middleware adds dispatch(action) to state AND api.dispatch(action),
plus api.dispatchFromDevtools = true so devtools will accept inbound
dispatches when stacked.
3.9 combine(initialState, secondCreator)
| Parameter | Type | Description |
|---|---|---|
initialState |
T (object) |
Seed object |
secondCreator |
StateCreator<T, Mps, Mcs, U> |
Returns the action half |
Returns StateCreator<Write<T, U>, Mps, Mcs>. No options.
3.10 unstable_ssrSafe(config, isSSR?)
| Parameter | Type | Default | Description |
|---|---|---|---|
config |
StateCreator<T, ...> |
— | Initializer to wrap |
isSSR |
boolean |
typeof window === 'undefined' |
When true, every set() throws Error('Cannot set state of Zustand store in SSR') |
Important
unstable_ssrSafeis exported asunstable_ssrSafeprecisely because the name and shape may change. Use only if you have an established SSR mismatch. The middleware comments cite discussion #2740 as the design thread.
4. Deployment and Scaling
There is no server to run. Zustand is a library; "deployment" means publishing your application bundle. This section covers how the package is published and how to scale your store usage.
4.1 Build pipeline (per package.json scripts)
The library is built with Rollup using one config that toggles by command-line flag:
pnpm run build # full multi-target build
pnpm run build:base # default config
pnpm run build:vanilla # vanilla bundle only
pnpm run build:react # react bundle only
pnpm run build:middleware
pnpm run build:middleware:immer
pnpm run build:shallow
pnpm run build:vanilla:shallow
pnpm run build:react:shallow
pnpm run build:traditional
pnpm run build-watch # watch mode for all sub-builds
A postbuild step (patch-d-ts, copy, patch-old-ts, patch-esm-ts)
rewrites .d.ts import paths to use bare specifiers (e.g. from './vanilla.ts' becomes from './vanilla'), copies output into both
dist/ and dist/esm/, generates the TS-version-required marker file,
and renames ESM declarations to .d.mts.
4.2 Scaling guidelines
| Concern | Guidance |
|---|---|
| Many stores | Fine. Each store is a closure; there is no global cost. Composition via slices is preferable to "the one big store". |
| Large state objects | Fine — setState only diffs by Object.is, not by value. Large objects are passed by reference. |
| High write rate | The hot loop is listeners.forEach. Keep listeners cheap; do work inside selectors, not subscribers. |
| Many components subscribed | Fine. React 18 batches updates; selector equality short-circuits. |
| Cross-tab sync | Implement via a storage event listener that calls useStore.persist.rehydrate(). Not built in. |
Tip
If a store's listener set grows past a few thousand on a single page, the bottleneck is almost always your selectors creating new references, not Zustand. Profile with React DevTools' "Highlight updates" toggle — only components selecting the changed slice should flash.
5. Operations
This section covers monitoring, runbooks, and lifecycle events for stores embedded in production applications.
5.1 Hydration runbook (persist middleware)
Scenario: a user reports stale data after a deploy that bumped
version.
1. Confirm the bump: diff store.ts version: 1 → 2
2. Confirm migrate: ensure migrate(state, 1) returns a v2-shape object
3. Hit hydrate path: open browser, attach console
4. Watch for warning: "State loaded from storage couldn't be migrated
since no migrate function was provided"
→ no migrate; add one and ship
5. Verify hydration: useStore.persist.hasHydrated() === true
6. Spot-check storage: localStorage[<name>] starts with {"state":...,"version":2}
Note
rehydrate()returns a Promise that resolves oncehasHydrated()returns true. TheonFinishHydrationlisteners fire after the resolve; theonHydratelisteners fire BEFORE the storage read (with the current in-memory state).
5.2 DevTools cleanup runbook
Scenario: in tests, multiple stores accumulate connections to the DevTools extension and slow down hot-reload.
// In test teardown:
useStore.devtools?.cleanup()
The middleware decrements the tracked connection map and calls
connection.unsubscribe() if present. Without cleanup, trackedConnections
will leak across tests when store option is used.
5.3 Monitoring
There is nothing to monitor at the library level — Zustand has no I/O, no timers, no workers, no global registry. Monitoring concerns at the consumer level:
| Metric | Strategy |
|---|---|
| Persist write failures | Wrap storage.setItem in your PersistStorage adapter and log |
| Hydration latency | Time rehydrate() Promise resolution; log if > N ms |
| Per-store update rate | Increment a counter inside an action; flush periodically |
| Memory: listener leaks | If a store's listener Set grows unboundedly, audit your useEffect cleanups |
Tip
A common runbook is to expose
__getStatein dev builds:if (process.env.NODE_ENV !== 'production') (window as any).useStore = useStore. That gives you a console handle to inspect any store live without React DevTools.
5.4 Cross-tab synchronization
Zustand does not synchronize across browser tabs. To do so, listen on the
storage event:
window.addEventListener('storage', (e) => {
if (e.key === 'cart-storage') {
useCart.persist.rehydrate()
}
})
This is enough for localStorage-backed stores. For more complex coordination,
use a BroadcastChannel-backed PersistStorage adapter.
6. Performance
6.1 Hot paths
The two hot paths are:
setState— typeof check on the partial,Object.isdiff, conditionalObject.assign(or replace),listeners.forEach.useStore— invoke selector,useSyncExternalStorereconciles.
Both are O(1) per listener. The amortized cost of listeners.forEach over a
state update is N (number of subscribers). React 18 batches state updates
across the same microtask, so multiple setState calls within the same
event handler reconcile once.
6.2 The Object.is short-circuit
if (!Object.is(nextState, state)) {
/* notify */
}
This single line is why "set the same value twice" is free. It also means:
- Setting
null→null: no notify. - Setting
NaN→NaN: no notify (Object.is(NaN, NaN) === true). - Setting
+0→-0: notify (Object.is(+0, -0) === false).
Note
Object.isis a reference equality for objects and arrays. Two object literals with the same shape are NOT equal. That's why the function-formset((state) => ({ ...state, x: 1 }))always notifies — it returns a new object each time.
6.3 Selector equality and useShallow
The default useStore selector check is Object.is(prev, next). If your
selector returns a new object every render (e.g. (s) => ({ a: s.a })),
React will re-render every state change because two object literals are
never Object.is-equal.
Three solutions:
| Solution | Cost | When to use |
|---|---|---|
| Select primitives one at a time | One hook call per field | The default |
useShallow(selector) |
One ref + one shallow compare | Selecting 2-5 keys at once |
createWithEqualityFn(initializer, shallow) |
Per-store default | Migrating from Zustand v3 |
The shallow function (src/vanilla/shallow.ts) handles plain objects,
arrays, Map, Set, and any iterable with entries(). URLSearchParams,
generators, and other iterables are also handled (with caveats — see the
test suite at tests/vanilla/shallow.test.tsx).
Tip
If a selector returns a derived value (e.g.
(s) => s.items.length),Object.isis fine — primitives compare by value.useShallowis only needed when the output shape is a fresh object/array.
6.4 Bundle size
Approximate sizes of the published artifacts at HEAD (after Rollup + esbuild minify):
| Bundle | Approx min+gzip |
|---|---|
zustand (full) |
~1.1 KB |
zustand/vanilla |
~0.6 KB |
zustand/middleware (all) |
~3.0 KB |
zustand/middleware/immer |
~0.4 KB (excludes immer itself) |
zustand/shallow |
~0.5 KB |
Tree-shaking works because package.json declares "sideEffects": false.
Importing only persist from zustand/middleware will not pull in
devtools.
7. Security
Zustand is a pure in-memory data structure with one optional I/O surface:
PersistStorage. There is no network, no eval, no dynamic code generation.
7.1 Threat surface
| Surface | Risk | Mitigation |
|---|---|---|
localStorage writes (persist) |
XSS can read/replace any persisted store | Standard XSS controls in the host app (CSP, sanitization). Consider encrypting sensitive fields before storage. |
migrate() |
Untrusted persisted data can flow into your reducer-style migration | Treat persistedState: unknown as untrusted. Validate before merging. |
devtools listener |
The Redux DevTools extension can dispatch arbitrary state from the panel | Disabled by default in production via import.meta.env.MODE check. |
redux reducer |
Reducer runs on every action — bugs become security if input is unvalidated | Same as any reducer pattern: validate action payloads. |
subscribe listeners |
Listener errors can break the notification loop for ALL listeners | Wrap untrusted listeners in try/catch, or never accept user-supplied listeners. |
Warning
The persist middleware's default
mergeis a shallow merge. If a malicious tab writes tolocalStoragewith extra keys, those keys WILL appear in your store. Validate the shape insidemergefor sensitive applications:merge: (persisted, current) => ({ ...current, ...validate(persisted) }).
7.2 SSR boundaries
The unstable_ssrSafe middleware exists to surface a security-adjacent bug:
writes during server rendering can leak request-scoped data into a
module-scoped store, which is then served to the next user. The middleware
throws Cannot set state of Zustand store in SSR to make that bug loud.
7.3 Dependencies
Zustand pulls in zero runtime dependencies for its core. The optional peers
are react, @types/react, use-sync-external-store, and immer. None
of those are loaded unless you import a code path that needs them.
DevDependencies (Rollup, ESLint, Vitest, etc.) ship in the repo but not in
the published package — see files: ["**"] and the postbuild copy step
that strips dev fields from the dist package.json.
Caution
If you fork the package or publish under a different name, run
npm auditagainst your fork's lockfile, not the upstream's. The upstream lockfile is for development; your published artifact has different dependency expectations.
8. Developer Internals
This section is the source-truth reference for contributors and for engineers building libraries on top of Zustand.
8.1 Repository structure
zustand/
├── src/
│ ├── index.ts # Public barrel: re-exports vanilla + react
│ ├── vanilla.ts # Core createStoreImpl (closure, listener Set)
│ ├── react.ts # create, useStore, UseBoundStore
│ ├── traditional.ts # createWithEqualityFn, useStoreWithEqualityFn
│ ├── shallow.ts # Re-exports shallow + useShallow
│ ├── middleware.ts # Public barrel for middleware
│ ├── types.d.ts # ImportMeta env declaration
│ ├── react/shallow.ts # useShallow hook
│ ├── vanilla/shallow.ts # shallow(a, b) implementation
│ └── middleware/
│ ├── combine.ts
│ ├── devtools.ts
│ ├── immer.ts
│ ├── persist.ts
│ ├── redux.ts
│ ├── ssrSafe.ts # unstable
│ └── subscribeWithSelector.ts
├── tests/
│ ├── basic.test.tsx
│ ├── devtools.test.tsx
│ ├── middlewareTypes.test.tsx # type-level tests covering middleware composition
│ ├── persistAsync.test.tsx
│ ├── persistSync.test.tsx
│ ├── setup.ts
│ ├── shallow.test.tsx
│ ├── ssr.test.tsx
│ ├── subscribe.test.tsx
│ ├── test-utils.ts
│ ├── types.test.tsx
│ └── vanilla/
│ ├── basic.test.ts
│ ├── shallow.test.tsx
│ └── subscribe.test.tsx
├── docs/ # Zustand's own user-facing documentation site
│ ├── learn/ # Getting-started, guides
│ └── reference/ # apis/, hooks/, integrations/, middlewares/, migrations/
├── examples/
│ ├── starter/ # Minimal Vite + React + TS counter
│ └── demo/ # 3D pmndrs-styled marketing demo
├── package.json
├── pnpm-workspace.yaml
├── rollup.config.mjs
├── eslint.config.mjs
├── vitest.config.mts
├── tsconfig.json
├── CONTRIBUTING.md
├── LICENSE # MIT, copyright Paul Henschel
└── README.md
8.2 The vanilla core, line by line
src/vanilla.ts defines createStoreImpl. Pseudocode:
const createStoreImpl = (createState) => {
let state // closure variable
const listeners = new Set<Listener>() // listener registry
const setState = (partial, replace) => {
const nextState = typeof partial === 'function' ? partial(state) : partial
if (Object.is(nextState, state)) return // (1) bail-out
const previousState = state
state =
(replace ?? (typeof nextState !== 'object' || nextState === null))
? nextState // replace mode
: Object.assign({}, state, nextState) // merge mode
listeners.forEach((l) => l(state, previousState)) // (2) notify
}
const getState = () => state
const getInitialState = () => initialState
const subscribe = (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
}
const api = { setState, getState, getInitialState, subscribe }
const initialState = (state = createState(setState, getState, api))
return api
}
Key invariants:
- (1) The
Object.isbail-out is the only perf check. There is no internal queue or batcher. React handles batching on the consumer side. - (2) Listener iteration is synchronous. A listener that throws will prevent later listeners in the set from running. This is intentional — Zustand chooses fail-fast over silent tolerance.
- The
replace ?? (typeof nextState !== 'object' || nextState === null)expression means non-object next states (null,string,number) are auto-replace, even if you didn't passreplace = true. getInitialStatereturns the original state captured during creation, not the current state — useful for diffing or for "reset to defaults" features.
8.3 The React adapter
src/react.ts defines useStore and create. The hook:
export function useStore(api, selector = identity) {
const slice = React.useSyncExternalStore(
api.subscribe,
React.useCallback(() => selector(api.getState()), [api, selector]),
React.useCallback(() => selector(api.getInitialState()), [api, selector]),
)
React.useDebugValue(slice)
return slice
}
Three key choices:
useSyncExternalStoreis the primitive — Zustand piggybacks on React's official external-store hook, so concurrent rendering, automatic batching, and tearing prevention are inherited.getInitialStateis the SSR snapshot — the third argument touseSyncExternalStoreis the server snapshot. By usinggetInitialState, server-rendered HTML matches the initial state and hydration mismatch is avoided.useDebugValuefor React DevTools — selectors show up labeled in the React DevTools "hooks" pane.
create wraps createStore:
const createImpl = (createState) => {
const api = createStore(createState)
const useBoundStore = (selector) => useStore(api, selector)
Object.assign(useBoundStore, api) // useBoundStore.getState etc.
return useBoundStore
}
That Object.assign is the trick that makes the returned hook ALSO a
StoreApi. You can call useBoundStore.getState() outside React because
getState was assigned onto the function object itself.
8.4 The traditional adapter
src/traditional.ts provides createWithEqualityFn and
useStoreWithEqualityFn. The difference vs. useStore:
import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector'
const slice = useSyncExternalStoreWithSelector(
api.subscribe,
api.getState,
api.getInitialState,
selector,
equalityFn, // ← the additional parameter
)
useSyncExternalStoreWithSelector performs the selector + equality dance
internally, so the hook avoids re-rendering when the equality function
returns true even if the underlying state changed. This is the v3-era
behavior and the reason the traditional entrypoint exists.
8.5 Middleware composition: the mutator chain
Every middleware declares a StoreMutators interface augmentation:
declare module '../vanilla' {
interface StoreMutators<S, A> {
'zustand/persist': WithPersist<S, A>
'zustand/devtools': WithDevtools<S>
'zustand/immer': WithImmer<S>
'zustand/redux': WithRedux<S, A>
'zustand/subscribeWithSelector': WithSelectorSubscribe<S>
}
}
combine does NOT register a mutator — it just changes the state shape
through the Write<T, U> helper.
The Mutate<S, Ms> recursive type folds a tuple of mutators into the final
store type:
type Mutate<S, Ms> =
number extends Ms['length' & keyof Ms] ? S
: Ms extends [] ? S
: Ms extends [[infer Mi, infer Ma], ...infer Mrs]
? Mutate<StoreMutators<S, Ma>[Mi & StoreMutatorIdentifier], Mrs>
: never
That's why setState overloads stay accurate after devtools(persist(...)):
each layer rewrites the setState type via Write<S, ...>.
Note
When TypeScript reports "Type instantiation is excessively deep and possibly infinite", you've usually hit a recursion limit on this type. Workaround: use the curried form
create<State>()(devtools(persist(...)))so TS infers in two steps, or annotate the mutator chain explicitly via the second generic.
8.6 Middleware deep dive: persist
The persist middleware is by far the most complex (~400 lines). The flow:
- Setup: merge defaults (
storage = createJSONStorage(() => localStorage),version = 0,partialize = identity,merge = shallow merge) with user options. - Storage swap: if
options.storageisundefined(e.g. SSR), wrapsetto print a warning and skip storage writes. api.setStateinterception: replaceapi.setStateso that every external write (e.g.useStore.setState(...)) is followed by a storage write throughsetItem().- Wrapped initializer: invoke the original
config(set, get, api)but pass asetthat also writes to storage on every call. - Hydration (unless
skipHydration: true):- Call
onRehydrateStorage(get())for pre-hydrate callback. - Read storage (sync OR Promise — the
Thenableadapter handles both). - If
versionmismatches, callmigrate(state, oldVersion). - Merge with current state via
options.merge. set(stateFromStorage, true)— replace mode.- Fire
onFinishHydrationlisteners. - Set
hasHydrated = true.
- Call
- Race-condition guard: each call to
hydrate()incrementshydrationVersion. Inside the async chain, every.thencheckscurrentVersion !== hydrationVersionand aborts if a newer hydration started. This prevents stale data from a slow read overwriting a fresh write. api.persist: the middleware attaches the persist API (setOptions,clearStorage,rehydrate,hasHydrated,onHydrate,onFinishHydration,getOptions).
Important
The
Thenablepolyfill (toThenable(fn)) lets the middleware treat sync and Promise-returning storage adapters uniformly. AsyncStorage (React Native), IndexedDB-backed adapters, and synchronous localStorage all flow through the same code path.
8.7 Middleware deep dive: devtools
devtools connects to window.__REDUX_DEVTOOLS_EXTENSION__ and:
- Bails to passthrough if
enabled === falseor the extension is absent. - Tracks connections in
trackedConnections: Map<ConnectionName, ConnectionInformation>so multi-store apps can share a single connection (whenstoreoption is used). - Replaces
api.setStatewith a version that ALSO sends an action to DevTools. The action name is the third argument toset, falling back toanonymousActionType, falling back to a stack-frame parse viafindCallerName(new Error().stack). - Subscribes to messages from the DevTools panel and handles
ACTION(manual dispatch,__setState),DISPATCHwith subtypesRESET,COMMIT,ROLLBACK,JUMP_TO_STATE,JUMP_TO_ACTION,IMPORT_STATE, andPAUSE_RECORDING. setStateFromDevtoolsflipsisRecording = falsewhile applying the inbound state so DevTools-driven changes don't echo back.
The middleware is also dispatchFromDevtools-aware: if the inner store
sets api.dispatchFromDevtools = true (the redux middleware does), then
DevTools panel dispatches flow into the user's reducer.
8.8 Middleware deep dive: immer
const immerImpl = (initializer) => (set, get, store) => {
store.setState = (updater, replace, ...args) => {
const nextState =
typeof updater === 'function' ? produce(updater) : updater
return set(nextState, replace, ...args)
}
return initializer(store.setState, get, store)
}
The middleware swaps store.setState so that function-form updaters get
wrapped in produce(...). produce calls your function with a draft,
collects mutations, and returns the new immutable state.
Caveat: immer.produce only supports plain objects, Maps, Sets, and arrays.
For Maps and Sets, the caller must call enableMapSet() from immer in
their app bootstrap. Class instances and Proxies are not supported.
8.9 Middleware deep dive: redux
The smallest middleware. It:
- Adds
dispatch(action)to the store API that callsset((state) => reducer(state, action), false, action). - Sets
api.dispatchFromDevtools = trueso thedevtoolsmiddleware, if stacked, will accept inbound dispatches. - Returns
{ dispatch, ...initial }as the seed state.
Because dispatch is part of state, both useStore.dispatch(...) (via
the Object.assign trick in create) and useStore((s) => s.dispatch)(...)
work in components.
8.10 Middleware deep dive: subscribeWithSelector
api.subscribe = ((selector, optListener, options) => {
let listener = selector
if (optListener) {
const equalityFn = options?.equalityFn || Object.is
let currentSlice = selector(api.getState())
listener = (state) => {
const nextSlice = selector(state)
if (!equalityFn(currentSlice, nextSlice)) {
const previousSlice = currentSlice
optListener((currentSlice = nextSlice), previousSlice)
}
}
if (options?.fireImmediately) optListener(currentSlice, currentSlice)
}
return origSubscribe(listener)
}) as any
The middleware overloads subscribe: passing one argument behaves like the
default (listener gets full state); passing a selector + listener wraps the
listener so it only fires when the selected slice changes.
Note
fireImmediately: truecalls the listener synchronously insidesubscribeitself, before the function returns. The current slice is passed for bothsliceandprevSliceparameters because there is no previous slice yet.
8.11 Middleware deep dive: combine
export function combine(initialState, create) {
return (...args) =>
Object.assign({}, initialState, create(...args))
}
That's the entire implementation. It returns a StateCreator that,
when invoked, builds { ...initialState, ...secondCreator(set, get, api) }.
The point of combine is type inference — the TypeScript signature unions
the two halves so you don't repeat your shape in a type alias.
8.12 Middleware deep dive: ssrSafe (unstable)
export function ssrSafe(config, isSSR = typeof window === 'undefined') {
return (set, get, api) => {
if (!isSSR) return config(set, get, api)
const ssrSet = () => {
throw new Error('Cannot set state of Zustand store in SSR')
}
api.setState = ssrSet
return config(ssrSet as never, get, api)
}
}
Marked unstable in source comments and exported as unstable_ssrSafe. The
discussion thread is pmndrs/zustand#2740. Use with care; the API may
change.
8.13 Server-side rendering
Zustand's behavior across SSR/hydration boundaries:
| Phase | Behavior |
|---|---|
| Server render | useStore calls the initial-state snapshot (third arg of useSyncExternalStore). State equals getInitialState(). No subscribe — the listener is registered only after mount. |
| Hydration | React's hydration uses the same initial snapshot, then attaches the subscriber. If the client store has already hydrated from localStorage, mismatch is possible — that's why persist exposes skipHydration. |
| Post-hydration | persist.rehydrate() replaces state with stored data. Components re-render via the standard listener path. |
Recommended Next.js pattern:
'use client'
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export const useStore = create()(
persist(initializer, {
name: 'app-store',
skipHydration: true, // server returns initial state; client rehydrates on mount
}),
)
// In a top-level client component:
useEffect(() => {
useStore.persist.rehydrate()
}, [])
Important
Do NOT define a Zustand store at module scope in a server component (Next.js App Router). The store will be shared across requests. Either use the
'use client'directive or create the store inside a Context Provider that lives in a client boundary.
8.14 Testing strategy
The project's own test suite (Vitest 4 + JSDOM 27 + @testing-library/react 16) is the reference. Three categories:
- Vanilla unit tests (
tests/vanilla/*.test.ts*): exercisecreateStore/setState/subscribesemantics, equality, edge cases (NaN, null, primitives, replace mode). - React tests (
tests/*.test.tsx): render with@testing-library/react, assert re-render counts, persist hydration, devtools dispatch, SSR snapshot equality. - Type tests (
tests/middlewareTypes.test.tsx,tests/types.test.tsx): useexpectTypeOffrom Vitest to assert the inferred types ofuseBoundStore((s) => s.x),useBoundStore.getState(),useBoundStore.subscribe(...), etc., across every combination of middleware (single, double, triple, quadruple stacks). These tests are the only way to verify the mutator-chain types stay sound.
To run:
pnpm install
pnpm run test # everything: format + types + lint + spec
pnpm run test:spec # vitest only
pnpm run test:types # tsc --noEmit
pnpm run test:lint # eslint
pnpm run test:format # prettier --list-different
Tip
When fixing a bug that requires a type-level reproduction, add a case to
tests/middlewareTypes.test.tsx. Type-only regressions are otherwise invisible to runtime tests.
8.15 Code patterns and conventions
Style:
- Prettier with
semi: false,singleQuote: true. The project's ownpackage.jsondeclares"prettier": { "semi": false, "singleQuote": true }. - Double-quoted JSX attributes (Prettier default).
- 2-space indent.
Imports:
- Source imports use
.tsextensions explicitly:import { ... } from './vanilla.ts'. Thepostbuildstep rewrites these to bare specifiers in.d.tsso consumers don't see the extension. - React is imported as a default import:
import React from 'react'. TheuseSyncExternalStorehook is accessed asReact.useSyncExternalStoreto allow thetraditionalentrypoint to swap in the shim.
TypeScript:
- Heavy use of conditional types (
Cast<T, U>,Write<T, U>,TakeTwo<T>,SkipTwo<T>). These are utility types for manipulating the parameter tuples of overloadedsetStatesignatures. - Mutator IDs are string literals (
'zustand/persist','zustand/devtools') registered via declaration merging on theStoreMutatorsinterface.
Errors:
- The library throws exactly ONE error string:
'Cannot set state of Zustand store in SSR'fromssrSafe. - Console warnings are used for non-fatal storage failures and migration
gaps. Search for
console.warnandconsole.errorinsrc/middleware/.
8.16 Potential improvements
These are observations for contributors, not committed roadmap items:
- The
ssrSafemiddleware is marked unstable and the author has noted dissatisfaction with the name (see comments and discussion #2740). Renaming with a deprecation alias would be a small high-impact PR. - The
findCallerNameheuristic indevtools.tsparsesnew Error().stack. Stack formats vary across engines; a more robust fallback (or removal in favor of the explicit-name path) would reduce surprise. - The
Thenablepolyfill inpersist.tsexists because the synchronous vs. async storage paths converge. With Promise-aware syntax everywhere in modern targets, this could be simplified post-Node-18 floor. migratereturningunknownrather than a typedPersistedStatemakes it easy to write a buggy migration that compiles. A stricter generic would help, at the cost of breaking changes.- The shallow comparator does not special-case Date, RegExp, or TypedArray. The test suite explicitly documents these as "unsupported" cases. Adding them would be a backward-compatible improvement.
8.17 Contributing
The project's CONTRIBUTING.md is the authoritative guide. Workflow:
- Fork and clone.
pnpm install(the project uses pnpm 10.18.3 as the package manager —package.jsonpackageManagerfield pins this).- Make your change in
src/. If it's a feature, add tests intests/(and a type test intests/middlewareTypes.test.tsxif it touches middleware). pnpm run test— must pass formatting, lint, types, and spec.pnpm run fix— auto-fix lint and format issues.pnpm run build— verify the build artifacts are sane.- Open a PR. The maintainers (Daishi Kato, Jeremy Holcomb, Paul Henschel) review.
Note
The
pnpm-workspace.yamlmakes the repo a workspace. Bothexamples/demoandexamples/starterare nested workspaces with their ownpackage.json. Changes tosrc/do not auto-rebuild for the examples — they importzustandfrom npm, not from the local source.
9. Integration Points
9.1 Public API surface (the contract you can rely on)
| Export | From | Stability |
|---|---|---|
create |
zustand |
stable |
createStore |
zustand and zustand/vanilla |
stable |
useStore |
zustand and zustand/vanilla |
stable |
createWithEqualityFn |
zustand/traditional |
stable |
useStoreWithEqualityFn |
zustand/traditional |
stable |
shallow |
zustand/shallow and zustand/vanilla/shallow |
stable |
useShallow |
zustand/shallow and zustand/react/shallow |
stable |
persist, createJSONStorage |
zustand/middleware |
stable |
devtools |
zustand/middleware |
stable |
subscribeWithSelector |
zustand/middleware |
stable |
combine |
zustand/middleware |
stable |
redux |
zustand/middleware |
stable |
immer |
zustand/middleware/immer |
stable |
unstable_ssrSafe |
zustand/middleware |
unstable — name and shape may change |
Types: StateCreator, StoreApi, StoreMutators, StoreMutatorIdentifier, Mutate, ExtractState, UseBoundStore, NamedSet, DevtoolsOptions, PersistOptions, PersistStorage, StateStorage, StorageValue |
various | stable |
9.2 Writing a custom middleware
Custom middleware follows the same shape as built-ins. A minimal logger:
import type { StateCreator, StoreMutatorIdentifier } from 'zustand'
type Logger = <
T,
Mps extends [StoreMutatorIdentifier, unknown][] = [],
Mcs extends [StoreMutatorIdentifier, unknown][] = [],
>(
initializer: StateCreator<T, Mps, Mcs>,
name?: string,
) => StateCreator<T, Mps, Mcs>
const loggerImpl =
<T>(initializer: StateCreator<T, [], []>, name = 'store') =>
(set, get, api) => {
const loggedSet: typeof set = (...args) => {
console.log(`[${name}] before`, get())
const result = set(...args)
console.log(`[${name}] after`, get())
return result
}
return initializer(loggedSet, get, api)
}
export const logger = loggerImpl as unknown as Logger
Three patterns to copy:
- The public type (
Logger) and the implementation type (its inner function) are different. The public type carries the generic mutator chain; the implementation works on simple state. - The cast
loggerImpl as unknown as Loggerat the bottom is what every built-in does. Without it, TS can't reconcile the simple inner type with the public mutator-chain type. - To register a new mutator (so downstream
setStateoverloads change), augmentStoreMutators:declare module 'zustand/vanilla' { interface StoreMutators<S, A> { 'org/logger': WithLogger<S> } }
The type-test suite (tests/middlewareTypes.test.tsx) demonstrates this
pattern with a synthetic 'org/example' mutator.
9.3 Embedding Zustand in a larger framework
Patterns observed in pmndrs-adjacent projects (jotai, valtio, leva):
| Use case | Pattern |
|---|---|
| Per-component-tree store | Create the store inside a React Context Provider; consumers use useStore(api, selector) from zustand |
| Server-rendered initial state | Pass props to the Provider, build the store with those props as initial state |
| Cross-package state | Export the useStore hook from a shared package; don't try to share the closure across module instances |
| Plugin systems | Each plugin gets its own store; orchestration via subscribe cross-store |
Tip
The official
examples/starter/src/index.tsxis a copy-paste-friendly baseline for a Vite + React + TypeScript + Zustand project. Theexamples/demofolder is the marketing demo (zustand-demo.pmnd.rs) with@react-three/fiber— useful as inspiration but heavier than a typical bootstrap.
9.4 Versioning
Per package.json, the current version is 5.0.12. The library follows
semver: minor versions add non-breaking features (or fix bugs that don't
require API change), major versions change exported types or runtime
behavior.
The docs/reference/migrations/ folder ships migration guides for the v3
to v4 and v4 to v5 transitions, and docs/reference/previous-versions/
preserves the v3 create-context API documentation. Consult those when
upgrading.
9.5 Community ecosystem
Zustand has a large ecosystem of integrations — third-party middleware
(undo/redo, computed values, IndexedDB persistence), framework adapters
(Vue, Svelte), and helper libraries. The docs/reference/integrations/
and docs/reference/middlewares/ folders track first-party integration
guides; the GitHub README links to community-maintained packages.
The repository's GitHub Discussions (especially the design threads
referenced in source comments — e.g. discussion #2740 for unstable_ssrSafe)
is where API directions are debated. Open issues #100 and #2659 indicate
ongoing community engagement (sentiment thank-you and a help-wanted
respectively); issue #3437 was the closed Metro/Expo import.meta.env
bug discussed in the User Guide troubleshooting section.
Note
The maintainers explicitly keep core minimal. Feature requests that would expand the published API surface tend to be redirected toward middleware patterns or third-party packages rather than absorbed into core. This is by design — Zustand's identity is "the smallest thing that works".