Jesse A. Green / notebook

Live demo: the DocCompiler guide library as a static, read-only snapshot (October 2026). No backend, no accounts, nothing to submit.

Zustand Administrator and Developer Guide

Generated April 2026 from pmndrs/zustand at 3201328

AI-generated documentation. The library is checked by sampling (three repositories so far); this guide was not individually reviewed. Verify against the project before relying on it.

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:

  1. A closure for state. state and listeners are private variables in a closure produced by a single createStoreImpl factory. There is no class, no proxy, and no global registry.
  2. A Set of listeners. Subscribers are registered into a JavaScript Set. setState iterates the set in insertion order and calls each listener with (nextState, prevState).
  3. useSyncExternalStore as 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.json type is commonjs, but the published shape is dual: the exports field maps import to ./esm/*.mjs and default to ./*.js, with a react-native condition 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.is check in setState is the single most important performance feature. It means setting count: 5 when count is already 5 is 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-native exports condition)
  • Edge runtimes (Cloudflare Workers, Vercel Edge, Deno)
  • Service Workers — anything that supports JavaScript modules

Important

import.meta.env?.MODE is read by the devtools middleware. Bundlers that don't define import.meta.env will see undefined and the optional-chain protects most cases — but Metro/Expo had a regression here (issue #3437, closed). When shipping to non-Vite environments, always pass enabled explicitly to devtools().

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_ssrSafe is exported as unstable_ssrSafe precisely 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 once hasHydrated() returns true. The onFinishHydration listeners fire after the resolve; the onHydrate listeners 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 __getState in 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:

  1. setState — typeof check on the partial, Object.is diff, conditional Object.assign (or replace), listeners.forEach.
  2. useStore — invoke selector, useSyncExternalStore reconciles.

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.is is a reference equality for objects and arrays. Two object literals with the same shape are NOT equal. That's why the function-form set((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.is is fine — primitives compare by value. useShallow is 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 merge is a shallow merge. If a malicious tab writes to localStorage with extra keys, those keys WILL appear in your store. Validate the shape inside merge for 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 audit against 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.is bail-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 pass replace = true.
  • getInitialState returns 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:

  1. useSyncExternalStore is the primitive — Zustand piggybacks on React's official external-store hook, so concurrent rendering, automatic batching, and tearing prevention are inherited.
  2. getInitialState is the SSR snapshot — the third argument to useSyncExternalStore is the server snapshot. By using getInitialState, server-rendered HTML matches the initial state and hydration mismatch is avoided.
  3. useDebugValue for 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:

  1. Setup: merge defaults (storage = createJSONStorage(() => localStorage), version = 0, partialize = identity, merge = shallow merge) with user options.
  2. Storage swap: if options.storage is undefined (e.g. SSR), wrap set to print a warning and skip storage writes.
  3. api.setState interception: replace api.setState so that every external write (e.g. useStore.setState(...)) is followed by a storage write through setItem().
  4. Wrapped initializer: invoke the original config(set, get, api) but pass a set that also writes to storage on every call.
  5. Hydration (unless skipHydration: true):
    • Call onRehydrateStorage(get()) for pre-hydrate callback.
    • Read storage (sync OR Promise — the Thenable adapter handles both).
    • If version mismatches, call migrate(state, oldVersion).
    • Merge with current state via options.merge.
    • set(stateFromStorage, true) — replace mode.
    • Fire onFinishHydration listeners.
    • Set hasHydrated = true.
  6. Race-condition guard: each call to hydrate() increments hydrationVersion. Inside the async chain, every .then checks currentVersion !== hydrationVersion and aborts if a newer hydration started. This prevents stale data from a slow read overwriting a fresh write.
  7. api.persist: the middleware attaches the persist API (setOptions, clearStorage, rehydrate, hasHydrated, onHydrate, onFinishHydration, getOptions).

Important

The Thenable polyfill (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:

  1. Bails to passthrough if enabled === false or the extension is absent.
  2. Tracks connections in trackedConnections: Map<ConnectionName, ConnectionInformation> so multi-store apps can share a single connection (when store option is used).
  3. Replaces api.setState with a version that ALSO sends an action to DevTools. The action name is the third argument to set, falling back to anonymousActionType, falling back to a stack-frame parse via findCallerName(new Error().stack).
  4. Subscribes to messages from the DevTools panel and handles ACTION (manual dispatch, __setState), DISPATCH with subtypes RESET, COMMIT, ROLLBACK, JUMP_TO_STATE, JUMP_TO_ACTION, IMPORT_STATE, and PAUSE_RECORDING.
  5. setStateFromDevtools flips isRecording = false while 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:

  1. Adds dispatch(action) to the store API that calls set((state) => reducer(state, action), false, action).
  2. Sets api.dispatchFromDevtools = true so the devtools middleware, if stacked, will accept inbound dispatches.
  3. 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: true calls the listener synchronously inside subscribe itself, before the function returns. The current slice is passed for both slice and prevSlice parameters 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:

  1. Vanilla unit tests (tests/vanilla/*.test.ts*): exercise createStore / setState / subscribe semantics, equality, edge cases (NaN, null, primitives, replace mode).
  2. React tests (tests/*.test.tsx): render with @testing-library/react, assert re-render counts, persist hydration, devtools dispatch, SSR snapshot equality.
  3. Type tests (tests/middlewareTypes.test.tsx, tests/types.test.tsx): use expectTypeOf from Vitest to assert the inferred types of useBoundStore((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 own package.json declares "prettier": { "semi": false, "singleQuote": true }.
  • Double-quoted JSX attributes (Prettier default).
  • 2-space indent.

Imports:

  • Source imports use .ts extensions explicitly: import { ... } from './vanilla.ts'. The postbuild step rewrites these to bare specifiers in .d.ts so consumers don't see the extension.
  • React is imported as a default import: import React from 'react'. The useSyncExternalStore hook is accessed as React.useSyncExternalStore to allow the traditional entrypoint 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 overloaded setState signatures.
  • Mutator IDs are string literals ('zustand/persist', 'zustand/devtools') registered via declaration merging on the StoreMutators interface.

Errors:

  • The library throws exactly ONE error string: 'Cannot set state of Zustand store in SSR' from ssrSafe.
  • Console warnings are used for non-fatal storage failures and migration gaps. Search for console.warn and console.error in src/middleware/.

8.16 Potential improvements

These are observations for contributors, not committed roadmap items:

  • The ssrSafe middleware 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 findCallerName heuristic in devtools.ts parses new Error().stack. Stack formats vary across engines; a more robust fallback (or removal in favor of the explicit-name path) would reduce surprise.
  • The Thenable polyfill in persist.ts exists because the synchronous vs. async storage paths converge. With Promise-aware syntax everywhere in modern targets, this could be simplified post-Node-18 floor.
  • migrate returning unknown rather than a typed PersistedState makes 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:

  1. Fork and clone.
  2. pnpm install (the project uses pnpm 10.18.3 as the package manager — package.json packageManager field pins this).
  3. Make your change in src/. If it's a feature, add tests in tests/ (and a type test in tests/middlewareTypes.test.tsx if it touches middleware).
  4. pnpm run test — must pass formatting, lint, types, and spec.
  5. pnpm run fix — auto-fix lint and format issues.
  6. pnpm run build — verify the build artifacts are sane.
  7. Open a PR. The maintainers (Daishi Kato, Jeremy Holcomb, Paul Henschel) review.

Note

The pnpm-workspace.yaml makes the repo a workspace. Both examples/demo and examples/starter are nested workspaces with their own package.json. Changes to src/ do not auto-rebuild for the examples — they import zustand from 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:

  1. 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.
  2. The cast loggerImpl as unknown as Logger at the bottom is what every built-in does. Without it, TS can't reconcile the simple inner type with the public mutator-chain type.
  3. To register a new mutator (so downstream setState overloads change), augment StoreMutators:
    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.tsx is a copy-paste-friendly baseline for a Vite + React + TypeScript + Zustand project. The examples/demo folder 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".