pmndrs/zustand
github.com/pmndrs/zustand ↗ · 130 files · 733 KB
A small, fast and scalable bearbones state-management solution using simplified flux principles.
Overview
Zustand is a TypeScript state-management library for React developers and applications that need stores outside React. Its core keeps state and synchronous subscriptions in a small closure; React bindings turn that API into selector-based hooks without requiring a provider. Optional middleware adds persistence, Redux DevTools, reducers, Immer updates, and selective subscriptions, while separate package entrypoints preserve the vanilla/React boundary.
System map
The package separates a framework-independent store from React consumers and opt-in middleware. State lives in memory unless a consumer supplies persistence middleware and storage. Rollup produces CommonJS, ESM, and declarations; GitHub Actions describes validation, package publication, previews, and documentation deployment.
- src/index.ts exports vanilla and React APIs; middleware, traditional equality hooks, and shallow helpers have separate source entrypoints.
- createStoreImpl owns state and listeners in a closure and exposes setState, getState, getInitialState, and subscribe through StoreApi.
- React useStore consumes a read-only StoreApi subset through useSyncExternalStore; traditional hooks instead import the with-selector shim.
- Persistence defaults to browser localStorage, while PersistStorage permits consumer-supplied synchronous or asynchronous storage adapters.
- The export map selects CommonJS or ESM JavaScript and corresponding declarations, including react-native conditions; React, Immer, and the selector shim are optional peers.
- Rollup selects src entrypoints from config flags and emits dist/*.js plus dist/esm/*.mjs; postbuild patches declarations and creates a publishable dist manifest.
- The two private Vite examples use the React SWC plugin; demo includes Three.js integrations, whereas starter contains a typed counter.
- Release publication builds and publishes dist to npm; preview CI publishes dist through pkg-pr-new, and docs CI delegates building to pmndrs/docs before deploying GitHub Pages.
Feature catalog
The principal user-facing surface is a library API for creating, reading, updating, and subscribing to stores. Equality helpers and middleware provide optional behavior without changing the basic store model. The examples demonstrate counters, snippet selection and copying, and an interactive graphical backdrop.
- createStore supports direct and curried initialization; create returns a React hook with the imperative store API attached, and useStore can consume an existing store.
- createWithEqualityFn supplies a default comparator, and useStoreWithEqualityFn accepts a per-call selector and equality function.
- subscribeWithSelector adds slice subscriptions, custom equalityFn, previous-slice arguments, and fireImmediately while retaining whole-state subscriptions.
- persist supports partialize, version migration, custom merge, hydration callbacks, skipHydration, and an imperative persist API for rehydration, options, listeners, and storage clearing.
- devtools records named actions, groups stores by connection, supports time travel/import/reset messages, and exposes cleanup; redux adds reducer-backed dispatch to state and the store API.
- immer accepts draft-mutating updater functions, combine merges initial data with creator output, and unstable_ssrSafe replaces setters with throwing functions in SSR mode.
- shallow compares matching-prototype objects and iterable values; useShallow reuses the preceding selected value when that comparison succeeds.
- Examples provide increment buttons, JavaScript/TypeScript snippet selection, clipboard copying with success feedback, and a pointer-responsive Three.js scene with an image fallback.
Data flow
State updates enter through values, updater functions, reducers, or middleware-wrapped setters. The vanilla store owns the resulting snapshot and notifies synchronous subscribers; React selects snapshots through its external-store interface. Persistence and DevTools create additional output and input boundaries around that same store.
- setState evaluates function input against current state, skips Object.is-identical results, then replaces or shallow-merges before notifying listeners with new and previous state.
- subscribe stores callbacks in a Set and returns deletion-based unsubscribe; getInitialState retains the initializer result separately from later state snapshots.
- useStore supplies selected getState snapshots for clients and selected getInitialState snapshots for server rendering, using the same api.subscribe boundary.
- Selective subscriptions cache currentSlice and pass next/previous slices only when equalityFn rejects equivalence; useShallow instead stabilizes the selected output reference.
- Persistence wraps both api.setState and the creator's set: each updates live state first, then partializes a shallow copy and writes a state/version envelope under options.name.
- createJSONStorage parses storage strings, awaits Promise reads, serializes writes with an optional replacer, and delegates removal to the selected adapter.
- Hydration checks stored versions, optionally migrates, merges with the latest available state, replaces live state, writes migrated data, and then signals completion unless its generation is obsolete.
- DevTools sends actions and current snapshots outward; incoming ACTION and DISPATCH messages can set state or dispatch reducers while setStateFromDevtools temporarily suppresses recording.
Behavior traces
The source supports concrete walks through initialization, counter updates, selective subscriptions, persistence, and external debugging. Synchronous persistence can affect the returned initial state, whereas asynchronous hydration completes after initialization. Clearing storage invalidates pending hydration but preserves live state.
- Startup: create(initializer) → createImpl → createStore → createStoreImpl → initializer(setState,getState,api) → assign state/initialState → attach api to useBoundStore.
- Starter action: Counter selects count/inc → button onClick invokes inc → functional setState increments count → listeners notify React → useStore reads the selected snapshot.
- Selective subscription: subscribe(selector,listener,options) captures currentSlice → optional immediate callback → origSubscribe registers wrapper → update computes nextSlice → unequal slices trigger listener.
- Synchronous persistence startup: persistImpl configures storage → invokes config → hydrate reads/parses storage → merge and set(...,true) → completion callback → returns stateFromStorage rather than configResult.
- Asynchronous hydration: hydrate increments hydrationVersion → bound getItem resolves → version mismatch may await migrate → generation check precedes merge/set → another check precedes completion signals; stale errors are also suppressed.
- Storage clearing: persist.clearStorage increments hydrationVersion before removeItem → delayed hydration sees a stale generation and skips application; the operation does not reset already hydrated live state.
- DevTools time travel: extension subscriber receives JUMP_TO_STATE/JUMP_TO_ACTION → parseJsonThen parses message.state → setStateFromDevtools disables recording → underlying set updates subscribers → recording is restored.
- Snippet flow: SnippetLang change calls setLang → CodePreview reads lang and getCode → selected source is highlighted and passed to CopyButton → handleCopy calls clipboard.writeText → success displays Copied! for three seconds.
Testing signals
Tests and CI were inspected, not executed; their existence supplies coverage intent, not passing results. Vitest uses jsdom, React Testing Library, and source aliases, while TypeScript checking separately validates API and middleware contracts. The inspected inventory is bounded: mocked adapters, synthetic DOM hydration, and configured compatibility matrices do not establish exhaustive runtime or browser coverage.
- Vitest searches tests, aliases zustand imports to src, enables globals for Testing Library cleanup, loads jest-dom setup, and configures V8 coverage without an explicit threshold.
- The inspected React counter test asserts both incremented DOM output and selector isolation: the counter renders twice while the action-only control renders once.
- Vanilla subscription tests cover identical state suppression, slice equality, custom comparators, unsubscribe, and fireImmediately; shallow tests cover primitive, object, array, and Map comparisons.
- Persistence tests assert synchronous round trips and read errors, concurrent last-hydration-wins behavior, and cancellation after clearStorage, including storage-removal exceptions.
- DevTools tests use mock connections and explicitly assert cleanup and option forwarding; these checks do not exercise a live installed browser extension.
- SSR tests renderToString and hydrateRoot in the configured DOM environment, including an update during hydration; Immer tests assert nested draft updates preserve previous objects.
- Middleware type tests include devtools/persist/immer composition and expectTypeOf assertions inside an unrendered TestComponent; CI separately invokes tsc --noEmit to validate types.
- Inspected CI specifies format/types/lint/spec/build checks, React 18–19 plus prerelease matrices, TypeScript 4.5–5.9 compatibility, and CJS/ESM tests; the build matrix enables development only, leaving production commented out.
Risk map
The material boundaries inspected are persisted data, browser-extension messages, server store ownership, and synchronous update propagation. These are source observations and explicitly identified hypotheses, not confirmed vulnerabilities. Packaging and examples also expose verification limits; the bounded inspection does not establish whole-repository absence of other sensitive paths.
- The persistence default writes the whole state to localStorage through JSON serialization; applications must use partialize or an appropriate adapter when state contains data that should not be stored there.
- Stored JSON is parsed and type-asserted without runtime schema validation; default hydration spreads persisted values over current state, so validation and domain-specific merge behavior belong at the adapter/migration/merge boundary.
- Persistence updates live state before setItem; a serialization or storage-write failure occurs after the in-memory update, with no rollback in these wrappers.
- Hydration generations prevent stale hydration application, but default merge still overwrites concurrent edits to stored keys; the test titled 'should not overwrite user state changes' actually expects count 100 to become 42.
- DevTools receives full state snapshots and accepts incoming state-changing messages; production-default disabling and JSON parse error handling do not provide application-state schema validation.
- The Next.js guide requires per-request stores and warns against RSC access to shared stores; unstable_ssrSafe rejects SSR writes but does not itself create request isolation.
- Hypothesis: a throwing synchronous listener can interrupt later listener notifications after state has already changed, because setState calls listeners.forEach without per-listener exception isolation. (inferred)
- Verification limits include a development-only built-package matrix and root checks that exclude examples; demo declares Zustand ^4.3.9 while the library is 5.0.15, so its behavior cannot establish current-source parity.
Extension points
Extension centers on StateCreator factories and higher-order middleware rather than a runtime plugin loader. TypeScript module augmentation tracks how middleware changes StoreApi. Storage, selectors, comparators, reducers, slices, and exported entrypoints are the principal customization boundaries.
- StateCreator receives setState, getState, and store; createStore accepts that factory, allowing consumer-defined state and actions without a registry.
- StoreMutators is an augmentable interface, and Mutate recursively applies mutator tuples; middleware such as Immer registers its API transformation through module augmentation.
- PersistStorage is the storage extension contract, while createJSONStorage adapts string backends and exposes reviver/replacer hooks for custom serialization.
- PersistOptions separates partialize, migrate, merge, and onRehydrateStorage; each customizes a distinct write or hydration stage.
- useStore accepts an existing store and selector, while traditional hooks accept equality functions and subscribeWithSelector accepts comparator/immediate-delivery options.
- redux injects a caller-supplied reducer and initial state; combine injects a creator whose output overrides overlapping initial-state keys.
- The documented slices pattern combines factories through object spread and applies middleware to the combined store, explicitly discouraging middleware inside individual slices.
- DevtoolsOptions extends the extension connector configuration and adds name/store/enabled/action naming; new packaged source entrypoints must fit Rollup's config-flag routing and the package subpath export map.
Contributor handoff
Start with the affected runtime module and its API/type contract before editing. Tests and CI were inspected, not executed, so all suggested validation remains future work when execution is authorized. Keep changes focused on source and meaningful regressions, and treat initialization, middleware ordering, hydration generations, and generated declarations as coupled contracts.
- Read vanilla.ts and react.ts first for store/hook changes, and read the affected middleware alongside middlewareTypes.test.tsx when altering wrapped APIs or overloads.
- Source imports use explicit .ts extensions and type-only imports; tsconfig enforces strictness, exact optional properties, unchecked-index awareness, verbatim module syntax, and isolated declarations.
- Preserve setState replacement semantics and the retained initial snapshot; replacing a state object can remove actions, and synchronously reading get() during initialization precedes state assignment.
- For persistence changes, read both sync/async suites and preserve generation checks around state application, completion, errors, and clearStorage; storage clearing must remain distinct from live-state reset.
- When execution is authorized, use the existing test:spec script to target affected suites and test:types for signature changes; inspected CI also requests format, lint, and build checks, but none ran during this investigation.
- Packaging edits require reading Rollup aliases/replacements and declaration postprocessing; generated ESM declarations change extensions and module-augmentation paths.
- Root type/lint scopes exclude the examples; inspect their own manifests and configs for example work, and account for demo's Zustand 4 dependency instead of assuming it tests local Zustand 5.
- CONTRIBUTING.md requests focused changes, regression tests, formatting, conventional commits, and discussion-led contributions rather than direct contributor PRs; publication and deployment are separate CI operations, not local validation steps.
Languages
| Language | Files | Share of source |
|---|---|---|
| Markdown | 47 | docs/config |
| TypeScript | 34 | 68% |
| JavaScript | 16 | 32% |
| YAML | 13 | docs/config |
| JSON | 7 | docs/config |
| CSS | 3 | docs/config |
| HTML | 2 | docs/config |
Start reading here
Probable source entrypoint: src/index.ts — inferred from the directory tree and manifest, not the upstream docs.
Source roots
src/ (16 files)
Source layout — src/
| Path | Files |
|---|---|
src/middleware/ | 7 |
src/react/ | 1 |
src/vanilla/ | 1 |
src/index.ts | 1 |
src/middleware.ts | 1 |
src/react.ts | 1 |
src/shallow.ts | 1 |
src/traditional.ts | 1 |
src/types.d.ts | 1 |
src/vanilla.ts | 1 |
Repository structure
| Top-level path | Files |
|---|---|
docs/ | 42 |
examples/ | 31 |
src/ | 16 |
tests/ | 15 |
.github/ | 14 |
Docs found: CONTRIBUTING.mdREADME.mddocs/index.mddocs/learn/getting-started/comparison.mddocs/learn/getting-started/introduction.mddocs/learn/guides/advanced-typescript.mddocs/learn/guides/auto-generating-selectors.mddocs/learn/guides/beginner-typescript.mddocs/learn/guides/connect-to-state-with-url-hash.mddocs/learn/guides/event-handler-in-pre-react-18.md
Package
package.json — zustand v5.0.15, 39 declared dependencies.
@eslint/js@redux-devtools/extension@rollup/plugin-alias@rollup/plugin-node-resolve@rollup/plugin-replace@rollup/plugin-typescript@testing-library/jest-dom@testing-library/react@types/node@types/react@types/react-dom@types/use-sync-external-store
CI & config entrypoints
README.md— Repository readmesrc/index.ts— TS entrypointexamples/demo/vite.config.js— Vite configexamples/starter/vite.config.ts— Vite config.github/workflows/preview-release.yml— GitHub Actions workflow.github/workflows/test-multiple-versions.yml— GitHub Actions workflow.github/workflows/publish.yml— GitHub Actions workflow.github/workflows/test.yml— GitHub Actions workflow.github/workflows/test-multiple-builds.yml— GitHub Actions workflow.github/workflows/test-old-typescript.yml— GitHub Actions workflow
Structural health signals
Average score 9.2/10 across 33 analyzed source files; 1 hotspot surfaced.
src/middleware/persist.ts— 403 lines, score 6.2 (Deep nesting, Long function/block)src/middleware/devtools.ts— 437 lines, score 6.9 (Deep nesting, Long function/block)examples/demo/src/components/Scene.jsx— 244 lines, score 6.9 (Deep nesting, High churn)
These are deterministic review leads — size, churn, and structural signals — not proof of a bug in this project.
Recent history
d7a5583cffd82026-09-29 — Clarify maintainer-guided contribution workflow (#3587)b57db4f86ef12026-08-25 — docs(create): document that state cannot be read during initialization (#3569)f094eebe9bd32026-08-19 — ci: build the docs with pmndrs/docs@v4 (#3570)ea612a500b7d2026-08-19 — docs: fix broken migrating-to-v5 link in README (#3567)b126c338444a2026-08-17 — Change CounterStore type from intersection to union (#3565)