# Zustand Administrator and Developer Guide

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.

```text
                      ┌──────────────────────────────┐
                      │           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:

```text
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:

```ts
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:

```bash
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`.

```text
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.

```ts
// 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:

```ts
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

```ts
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:

```ts
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:

```ts
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`:

```ts
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`:

```ts
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:

```ts
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:

```ts
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

```ts
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

```ts
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

```ts
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)

```ts
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**:

```tsx
'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:

```bash
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:

```ts
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`:
   ```ts
   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".

---

_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. Unofficial; not affiliated with or reviewed by the project._
