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 User 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.

Zustand is a small, fast, and unopinionated state-management library for React and vanilla JavaScript. The name is German for "state". A single create call returns a hook that doubles as the store API — components subscribe to slices they care about, and only those components re-render when the slice changes.

This guide teaches you how to install Zustand, build a store, select state, write actions (synchronous and asynchronous), compose stores from slices, apply each shipped middleware, and use the library safely in TypeScript. Architecture, internals, and SSR mechanics live in the Admin Guide.

1. Quick Start

Zustand replaces Redux/MobX/Context boilerplate with a single hook. There is no provider, no reducer pattern (unless you want one), and no derived state machinery. State lives in a closure; subscribers are a Set of listeners; React reads via useSyncExternalStore.

Key features at a glance:

  • One function — create — produces both the hook and the imperative StoreApi.
  • Selector-based subscriptions: components only re-render when the selected slice changes (Object.is comparison by default).
  • Works with React, React Native, and any non-React environment via zustand/vanilla.
  • Six middleware shipped in the box: persist, devtools, immer, redux, subscribeWithSelector, combine, plus the experimental unstable_ssrSafe.
  • TypeScript-first: middleware types compose through a StoreMutators registry so setState overloads stay accurate after stacking middleware.
  • Zero dependencies for core; use-sync-external-store is a peer dependency.
  • Tree-shakable ESM and CJS builds.

Prerequisites: Node.js >= 12.20.0, React >= 18 (peer), and a TypeScript version >= 4.5 if you use TypeScript.

Install (pick your package manager):

npm install zustand

Minimal store — counter in 8 lines:

import { create } from 'zustand'

type CounterState = {
  count: number
  inc: () => void
}

const useStore = create<CounterState>((set) => ({
  count: 0,
  inc: () => set((state) => ({ count: state.count + 1 })),
}))

Use it in a component:

function Counter() {
  const count = useStore((s) => s.count)
  const inc = useStore((s) => s.inc)
  return <button onClick={inc}>{count}</button>
}

Expected behavior on click: the displayed number increments by 1 and only the Counter component re-renders, not its parent.

Tip

You can read the entire state at once by calling the hook with no arguments: const { count, inc } = useStore(). It works, but every state change will re-render that component. Prefer selectors for performance.

Try next: read the Selectors section, then jump to Middleware to add persistence or Redux DevTools.

2. Installation

Zustand ships as a single npm package with multiple ESM-aware entry points (zustand, zustand/vanilla, zustand/middleware, zustand/middleware/immer, zustand/shallow, zustand/traditional). Bundlers that respect the exports field of package.json will pick the right file automatically.

2.1 Requirements

Item Minimum
Node.js 12.20.0
React (peer, optional) 18.0.0
@types/react (peer, optional) 18.0.0
use-sync-external-store (peer, optional) 1.2.0
immer (peer, only if using the immer middleware) 9.0.6
TypeScript (if used) 4.5

Important

All peer dependencies are marked optional. You only pay for what you import. A vanilla-only project that imports from zustand/vanilla does not need React installed.

2.2 Install methods

npm

npm install zustand

pnpm

pnpm add zustand

yarn

yarn add zustand

bun

bun add zustand

2.3 Verify the install

Drop the snippet below into a new file (smoke.ts or smoke.js) and run it with tsx, ts-node, or node (after compiling to JS):

import { createStore } from 'zustand/vanilla'

const store = createStore<{ count: number }>(() => ({ count: 0 }))
store.subscribe((next) => console.log('count is', next.count))
store.setState({ count: 1 })
store.setState({ count: 2 })

Expected output:

count is 1
count is 2

If those two lines appear, the install is healthy. The vanilla path has zero React surface, so this works in Node, Deno, edge runtimes, and tests.

Note

The library is published as a single package; there are no per-feature sub-packages to install. zustand/middleware is just a file inside the same npm tarball.

2.4 Project setup checklist

  • [x] zustand is added to dependencies (not devDependencies).
  • [x] React (or your framework) is already installed.
  • [x] You set "strict": true in tsconfig.json if using TypeScript — the library's mutator types depend on strict inference to compose cleanly.
  • [ ] Decide where store files live. A common layout: src/stores/useUserStore.ts, src/stores/useCartStore.ts.
  • [ ] Decide on a slice/middleware strategy before the store grows past 50 lines.

3. Configuration

Zustand has no configuration file, no environment variables, and no global toggles. Everything is configured in code by composing middleware at store creation time. The only "config" surface is per-store options for individual middleware (for example, persist's name and version).

Tip

If you find yourself wanting global config, you almost always want a slice instead. See the Slices Pattern section.

The settings most users touch are:

Setting Where it lives Default
Persist storage backend persist(..., { storage }) createJSONStorage(() => window.localStorage)
Persist storage key persist(..., { name }) required
Devtools store name devtools(..., { name }) none (anonymous in DevTools panel)
Devtools enabled devtools(..., { enabled }) import.meta.env?.MODE !== 'production'
Subscribe equality function subscribe(selector, listener, { equalityFn }) Object.is

For the full per-middleware option set, see the Admin Guide section "Configuration Reference".

4. Core Usage

This section covers the everyday API: creating a store, reading state with selectors, writing actions, async actions, organizing with slices, and the React-vs-vanilla split.

4.1 Create a store

create is the React-bound entry point. It accepts a state creator — a function that receives (set, get, api) and returns the initial state object.

import { create } from 'zustand'

type BearState = {
  bears: number
  increase: (by: number) => void
  reset: () => void
}

const useBearStore = create<BearState>((set) => ({
  bears: 0,
  increase: (by) => set((state) => ({ bears: state.bears + by })),
  reset: () => set({ bears: 0 }),
}))

The returned useBearStore is both a React hook and a StoreApi object. That means useBearStore.getState(), useBearStore.setState(...), useBearStore.subscribe(...), and useBearStore.getInitialState() are all available outside React.

Note

The two-step create<State>()(initializer) form (with the empty parens) is required when you stack middleware in TypeScript. The empty call helps TypeScript infer the mutator chain. If you do not use middleware, the single-call form create<State>(initializer) works.

4.2 Read state with selectors

A selector is a function (state) => slice. The hook re-runs the selector on every state change and triggers a React render only if the new slice is not Object.is-equal to the previous one.

// Re-renders only when bears changes.
const bears = useBearStore((s) => s.bears)

// Re-renders only when the increase function reference changes (effectively never).
const increase = useBearStore((s) => s.increase)

// Reads the whole state. Re-renders on EVERY state change. Avoid in hot paths.
const everything = useBearStore()

Selecting multiple values has a subtlety: returning a new object on every call defeats the equality check, because { a, b } !== { a, b } by reference. Use the useShallow helper from zustand/shallow:

import { useShallow } from 'zustand/shallow'

const { bears, increase } = useBearStore(
  useShallow((s) => ({ bears: s.bears, increase: s.increase })),
)

useShallow memoizes the selector so it returns the same reference when each key compares shallow-equal. Internally it stores the previous slice in a ref and runs shallow(prev, next) from zustand/vanilla/shallow.

Warning

Do NOT inline a multi-key selector without useShallow. Code like useBearStore((s) => ({ bears: s.bears, increase: s.increase })) causes a re-render on every state change, even ones that don't touch those keys.

4.3 Write state with actions

Actions are just functions stored in state. They call set to update.

type CartState = {
  items: string[]
  add: (item: string) => void
  remove: (item: string) => void
  clear: () => void
}

const useCart = create<CartState>((set, get) => ({
  items: [],
  add: (item) => set((state) => ({ items: [...state.items, item] })),
  remove: (item) =>
    set((state) => ({ items: state.items.filter((i) => i !== item) })),
  clear: () => set({ items: [] }),
}))

Two forms of set:

Form Behavior
set({ items: [...] }) Object form — shallow-merges with previous state
set((state) => ({ items: [...] })) Function form — receives previous state, returns partial
set(state, true) Replace mode — REPLACES state entirely (no merge)

The shallow-merge default means set({ items: [...] }) keeps add, remove, and clear intact even though they aren't included in the partial. To replace the entire state (for example, to reset to an initial snapshot), pass true as the second argument.

Note

If set produces a new state value that is Object.is-equal to the current state, listeners are NOT called. Setting NaN to NaN is a no-op for subscribers. Setting state to null works for both object and primitive stores.

get() returns the current state without subscribing — useful inside actions that need to read before they write:

const useCounter = create<{ count: number; inc: () => void }>(
  (set, get) => ({
    count: 0,
    inc: () => set({ count: get().count + 1 }),
  }),
)

4.4 Async actions

Actions can be async. Just mark them async and await inside. State stays consistent because each set is synchronous; only the orchestration is async.

type AuthState = {
  user: { id: string; name: string } | null
  loading: boolean
  error: string | null
  login: (username: string, password: string) => Promise<void>
  logout: () => void
}

const useAuth = create<AuthState>((set) => ({
  user: null,
  loading: false,
  error: null,
  login: async (username, password) => {
    set({ loading: true, error: null })
    try {
      const res = await fetch('/api/login', {
        method: 'POST',
        body: JSON.stringify({ username, password }),
      })
      if (!res.ok) throw new Error('Invalid credentials')
      const user = await res.json()
      set({ user, loading: false })
    } catch (e) {
      set({ loading: false, error: (e as Error).message })
    }
  },
  logout: () => set({ user: null }),
}))

Tip

Always set loading: false in BOTH the success and error branches. A finally block also works. Forgetting this leaves the UI permanently in a spinning state.

4.5 Slices pattern

For larger stores, split state into focused slices and combine them. Each slice is a StateCreator that returns its piece. Spreading the slices builds the final state object.

import { create, StateCreator } from 'zustand'

interface BearSlice {
  bears: number
  addBear: () => void
}

interface FishSlice {
  fishes: number
  addFish: () => void
}

const createBearSlice: StateCreator<BearSlice & FishSlice, [], [], BearSlice> =
  (set) => ({
    bears: 0,
    addBear: () => set((state) => ({ bears: state.bears + 1 })),
  })

const createFishSlice: StateCreator<BearSlice & FishSlice, [], [], FishSlice> =
  (set) => ({
    fishes: 0,
    addFish: () => set((state) => ({ fishes: state.fishes + 1 })),
  })

const useBoundStore = create<BearSlice & FishSlice>()((...a) => ({
  ...createBearSlice(...a),
  ...createFishSlice(...a),
}))

Each slice declares the full combined state in its first generic parameter — that's how an action in BearSlice can call set to update fields owned by FishSlice (for example, eating a fish to gain bears).

The fourth generic (BearSlice / FishSlice in the examples above) tells TypeScript what subset of the combined state this slice contributes. The type-test suite shows this pattern composing cleanly with the immer middleware too.

4.6 React vs vanilla

Zustand has two entry points:

Entry API When to use
zustand create, useStore React components
zustand/vanilla createStore, useStore Node, edge runtimes, tests, non-React frameworks

createStore returns a plain StoreApi. There is no hook — you subscribe manually:

import { createStore } from 'zustand/vanilla'

type Counter = { count: number; inc: () => void }

const counter = createStore<Counter>((set) => ({
  count: 0,
  inc: () => set((s) => ({ count: s.count + 1 })),
}))

const unsubscribe = counter.subscribe((state, prev) => {
  console.log(`count: ${prev.count} → ${state.count}`)
})

counter.getState().inc() // logs: count: 0 → 1
unsubscribe()

You can also bridge a vanilla store into React with useStore from zustand:

import { useStore } from 'zustand'
const count = useStore(counter, (s) => s.count)

This is the pattern the library uses internally — create itself wraps a createStore call and binds it to React.

4.7 Equality functions and useStoreWithEqualityFn

The default equality is Object.is. For values that need a different check (for example, comparing arrays or objects shallowly without useShallow), use the traditional entry point:

import { createWithEqualityFn } from 'zustand/traditional'
import { shallow } from 'zustand/shallow'

const useStore = createWithEqualityFn<MyState>(
  (set) => ({ /* ... */ }),
  shallow, // default equality for all selectors of this store
)

createWithEqualityFn uses useSyncExternalStoreWithSelector from use-sync-external-store/shim/with-selector, which accepts a selector and equality function in one call. The selector is run each render, and the equality function decides whether to bail out.

Note

createWithEqualityFn exists for backward compatibility with Zustand v3's equality semantics and for users who prefer per-selector equality without wrapping every selector in useShallow. New code can use either.

4.8 The StoreApi surface

Whether created with create or createStore, every store exposes:

Method Type Purpose
getState() () => T Current state snapshot
getInitialState() () => T Initial state (set once at creation)
setState(partial, replace?) (partial | (state) => partial, boolean?) => void Update state
subscribe(listener) (listener) => unsubscribe Listen to all state changes

The React hook adds nothing to that surface — it just wraps subscribe and getState in useSyncExternalStore. You can call useBearStore.getState() directly inside event handlers, async callbacks, or non-React code.

5. Common Tasks

This section walks through 10 real-world recipes pulled from idiomatic Zustand usage.

5.1 Add a persist middleware (localStorage)

Goal: keep the cart in localStorage so it survives a page reload.

import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'

type CartState = {
  items: string[]
  add: (item: string) => void
}

const useCart = create<CartState>()(
  persist(
    (set) => ({
      items: [],
      add: (item) => set((s) => ({ items: [...s.items, item] })),
    }),
    {
      name: 'cart-storage', // localStorage key
      storage: createJSONStorage(() => localStorage),
    },
  ),
)

Verify: open DevTools, then Application, then Local Storage. After calling useCart.getState().add('apple') you'll see a cart-storage key with {"state":{"items":["apple"]},"version":0}.

Tip

The storage option defaults to createJSONStorage(() => window.localStorage), so for the simplest case you can omit it: persist(initializer, { name: 'cart' }).

5.2 Persist only part of state

Goal: keep theme and locale in localStorage but never persist cart.

const useUI = create<UIState>()(
  persist(
    (set) => ({ theme: 'dark', locale: 'en', cart: [] }),
    {
      name: 'ui',
      partialize: (state) => ({ theme: state.theme, locale: state.locale }),
    },
  ),
)

partialize runs once per write and returns the slice of state that should be serialized. Anything it omits stays in memory but never touches storage.

5.3 Persist with versioning and migration

Goal: rename username to displayName in v2 of your stored shape without losing old data.

const useAccount = create<AccountState>()(
  persist(
    (set) => ({ displayName: '', email: '' }),
    {
      name: 'account',
      version: 2,
      migrate: (persistedState: any, version) => {
        if (version === 1) {
          return { ...persistedState, displayName: persistedState.username }
        }
        return persistedState
      },
    },
  ),
)

When the stored version differs from the current version, Zustand calls migrate(state, oldVersion) and uses its return value. If you bump version without supplying migrate, Zustand prints State loaded from storage couldn't be migrated since no migrate function was provided to the console and falls back to initial state.

5.4 Add Redux DevTools

Goal: see every state transition in the Redux DevTools browser extension.

import { create } from 'zustand'
import { devtools } from 'zustand/middleware'

const useStore = create<MyState>()(
  devtools(
    (set) => ({
      count: 0,
      inc: () => set((s) => ({ count: s.count + 1 }), false, 'inc'),
      reset: () => set({ count: 0 }, false, 'reset'),
    }),
    { name: 'my-store' },
  ),
)

Pass an action name as the third argument to set (after replace) to label the entry in DevTools. Without it, the entry is labeled by the anonymousActionType option (default 'anonymous') or, in some browsers, parsed from the call stack.

Important

Install the Redux DevTools browser extension first (Chrome or Firefox). Without the extension, the middleware no-ops silently — no error.

5.5 Multiple stores in one DevTools panel

If you have two stores, the store option groups them under one connection name:

const useUser = create<UserState>()(
  devtools(initUser, { name: 'app', store: 'user' }),
)
const useCart = create<CartState>()(
  devtools(initCart, { name: 'app', store: 'cart' }),
)

DevTools will show both stores as a single tree keyed by user and cart, and time-travel works across them via the store key in payloads.

5.6 Use Immer for nested updates

Goal: mutate deeply nested state without spreading.

import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'

type State = {
  user: { profile: { name: string; age: number } }
  setName: (name: string) => void
}

const useStore = create<State>()(
  immer((set) => ({
    user: { profile: { name: '', age: 0 } },
    setName: (name) =>
      set((state) => {
        state.user.profile.name = name // direct "mutation" — Immer makes it safe
      }),
  })),
)

Behind the scenes, the immer middleware passes your function-form set through produce from the immer package, which captures the mutations and applies them immutably.

Warning

The immer package is a peer dependency. Run npm install immer before importing from zustand/middleware/immer. The peerDependencies list requires immer >= 9.0.6.

5.7 Subscribe with a selector outside React

Goal: react to changes in state.items.length from a non-React module.

import { create } from 'zustand'
import { subscribeWithSelector } from 'zustand/middleware'

const useCart = create<CartState>()(
  subscribeWithSelector((set) => ({ items: [], add: (i) => set((s) => ({ items: [...s.items, i] })) })),
)

const unsubscribe = useCart.subscribe(
  (state) => state.items.length,
  (count, prevCount) => console.log(`items: ${prevCount} → ${count}`),
  { equalityFn: Object.is, fireImmediately: true },
)

Without subscribeWithSelector, subscribe only takes a one-argument listener that fires on every state change. The middleware overloads subscribe to take a selector + listener + options and only fires when the selector's output changes.

5.8 Use a Redux-style reducer

Goal: dispatch typed actions instead of calling functions.

import { create } from 'zustand'
import { redux } from 'zustand/middleware'

type Action = { type: 'INC' } | { type: 'DEC' }

const useStore = create(
  redux(
    (state: { count: number }, action: Action) => {
      switch (action.type) {
        case 'INC': return { ...state, count: state.count + 1 }
        case 'DEC': return { ...state, count: state.count - 1 }
        default: return state
      }
    },
    { count: 0 },
  ),
)

// In a component:
const count = useStore((s) => s.count)
useStore.dispatch({ type: 'INC' })

The redux middleware adds a dispatch(action) method that runs your reducer and sets the result. It also flips dispatchFromDevtools = true, which makes DevTools (when stacked with devtools) able to dispatch actions back into the store via the panel's "Dispatcher" tab.

5.9 Use combine to drop the type annotation

Goal: avoid writing the state interface twice.

import { create } from 'zustand'
import { combine } from 'zustand/middleware'

const useStore = create(
  combine(
    { count: 0, name: '' },
    (set) => ({
      inc: () => set((s) => ({ count: s.count + 1 })),
      setName: (name: string) => set({ name }),
    }),
  ),
)
// useStore type is inferred as { count: number; name: string; inc: () => void; setName: (name: string) => void }

combine(initialState, secondCreator) returns a StateCreator that produces { ...initialState, ...secondCreator(set, get, api) }. TypeScript infers the final shape from the union, so you don't repeat your fields in a type alias.

5.10 Reset the store to initial state

There is no built-in reset(). Capture the initial state and restore it:

const initialState = { bears: 0, fish: 0 }

const useStore = create<State>((set) => ({
  ...initialState,
  reset: () => set(initialState, true), // replace mode
  // ... other actions
}))

The replace = true argument is critical: without it, your action functions would be wiped out by the merge. With it, the entire state is replaced — but because actions are part of initialState, they survive.

Tip

If you have multiple stores that need a global reset (for example, on logout), keep the initial-state objects in module scope and call each store's reset action from a single function.

6. Troubleshooting

Error: Cannot set state of Zustand store in SSR

Cause: You wrapped your store with unstable_ssrSafe and an action that calls set ran on the server (where typeof window === 'undefined'). The SSR-safe middleware throws on every server-side write because writes during hydration cause client/server mismatch.

Solution:

  1. Move the offending set into a useEffect so it only runs on the client.
  2. If the write is intentionally server-side (rare), don't use ssrSafe.
  3. Audit whether the write should be in the store at all — initial fetches often belong in a server component or loader, not in client state.

Prevention: only wrap with unstable_ssrSafe if you have a documented SSR mismatch. For most React 18 apps, the default behavior is fine because useSyncExternalStore already returns the initial state on the server.

Re-renders happen on every state change, not just my slice

Cause: The selector returns a new object/array reference each call.

Solution: use useShallow:

import { useShallow } from 'zustand/shallow'
const { a, b } = useStore(useShallow((s) => ({ a: s.a, b: s.b })))

Or select primitives one at a time:

const a = useStore((s) => s.a)
const b = useStore((s) => s.b)

Prevention: never inline a selector that returns a new object literal without useShallow.

Persisted state never appears

Cause: storage is undefined when the store initializes (SSR or no-window environments). Zustand prints Unable to update item '<name>', the given storage is currently unavailable. and falls back to in-memory state.

Solution: gate createJSONStorage on the existence of window:

storage: createJSONStorage(() =>
  typeof window !== 'undefined' ? localStorage : (undefined as any),
)

The createJSONStorage factory catches the throw and returns undefined, which the persist middleware then treats as "no storage".

Prevention: in Next.js, set skipHydration: true and call useStore.persist.rehydrate() from a useEffect. See the Admin Guide section "Server-Side Rendering" for the full pattern.

Stack overflow in setState after enabling devtools

Cause: A set call inside a subscribe listener triggers another DevTools send, which (in some configurations) re-enters via a JUMP_TO message.

Solution: don't call setState from inside a subscribe listener. Use subscribeWithSelector so listeners only fire on selected changes, and route writes through actions on the store, not from the listener.

Prevention: keep listeners side-effect-free; if you must mutate, use queueMicrotask(() => useStore.setState(...)) to break the synchronous loop.

TypeScript: "Type instantiation is excessively deep"

Cause: deeply nested middleware stack confuses TS's recursive mutator type. Often appears with 3+ middleware on a strict config.

Solution: use the curried form to give TypeScript the state type up front:

const useStore = create<MyState>()(
  devtools(persist(immer(initializer), { name: 'x' }), { name: 'y' }),
)

Or annotate the mutator chain explicitly:

const useStore = create<
  MyState,
  [
    ['zustand/devtools', never],
    ['zustand/persist', MyState],
    ['zustand/immer', never],
  ]
>(devtools(persist(immer(...), {name:'x'}), {name:'y'}))

Prevention: keep middleware stacks shallow; consolidate persistence, devtools, and immer into a single combine-style helper if you reuse them across many stores.

Store updates don't trigger React re-render in tests

Cause: Tests using react-test-renderer or older test setups may not flush useSyncExternalStore correctly.

Solution: use @testing-library/react (the same library Zustand's own test suite uses, version ^16.3.2). Wrap state-changing calls in act:

import { act } from '@testing-library/react'
act(() => useStore.getState().increment())

Prevention: import from @testing-library/react, not the older react-test-renderer. JSDOM 27+ is what the project's own vitest config uses for component tests.

import.meta.env is undefined in non-Vite bundlers (issue #3437)

Cause: the devtools middleware reads import.meta.env?.MODE to decide whether to enable. Bundlers that don't polyfill import.meta.env (Expo Metro, certain webpack configs) can throw at runtime.

Solution: pass enabled explicitly to bypass the check:

devtools(initializer, {
  name: 'my-store',
  enabled: process.env.NODE_ENV !== 'production',
})

Prevention: in non-Vite environments, always pass enabled to devtools(). The optional-chaining is defensive but not a polyfill.

Caution

Issue #3437 (closed) tracked the original bug. If you upgrade an Expo or Metro project from an older Zustand and see a runtime Cannot read properties of undefined (reading 'env'), this is the cause.

7. FAQ

Q: Do I need a Provider component?

No. Zustand stores are module-scoped singletons. The hook reaches into the store directly via useSyncExternalStore. If you need request-scoped state (SSR, micro-frontends), create the store inside a React Context — see the Admin Guide section "Server-Side Rendering" for the recommended pattern.

Q: How do I share state between two stores?

Don't. Compose them with the slices pattern (Section 4.5) or read across stores via getState() inside an action:

const useA = create((set) => ({ /* ... */ }))
const useB = create((set) => ({
  doThing: () => {
    const aValue = useA.getState().value
    set({ result: aValue * 2 })
  },
}))

Cross-store subscriptions also work via useA.subscribe(...) from inside a createStore call's body or a module-level effect.

Q: When should I use useShallow vs createWithEqualityFn?

Use useShallow for one-off shallow selections. Use createWithEqualityFn when you want every selector on a store to default to a non-Object.is equality. Most apps stick with create plus useShallow per call site.

Q: Can I use Zustand without React?

Yes — import from zustand/vanilla. The library was built so the React adapter is just a thin wrapper. Vue, Svelte, lit-html, plain DOM apps, and Node services all use the vanilla path.

Q: Does Zustand support React Server Components?

Stores are client-side. Mark files that import zustand with the 'use client' directive in Next.js App Router. To pass server-fetched data into a client store, use the props-as-initial-state pattern: hoist the store creation into a Context Provider that takes initial state from props.

Q: What about React Native?

Same package. The package.json exports field has a react-native condition that resolves to the same files as web. For persistence, use @react-native-async-storage/async-storage as your storage backend:

import AsyncStorage from '@react-native-async-storage/async-storage'
persist(initializer, {
  name: 'app',
  storage: createJSONStorage(() => AsyncStorage),
})

AsyncStorage returns Promises; createJSONStorage and persist handle async storage transparently.

Q: How do I time-travel with DevTools?

Wrap your store with devtools (Section 5.4), open the Redux DevTools extension, and use the slider at the bottom of the panel. JUMP_TO_STATE, JUMP_TO_ACTION, COMMIT, RESET, ROLLBACK, IMPORT_STATE, and PAUSE_RECORDING are all supported by the middleware.

Q: How do I unsubscribe?

subscribe returns the unsubscribe function. Call it when you're done:

const unsub = useStore.subscribe((state) => { /* ... */ })
// later:
unsub()

In React components, call subscribe from a useEffect and return the unsubscribe from the cleanup callback.

Q: Is the store mutable? Can I just do state.x = 1?

No. Always go through set. Direct mutation skips the listener notification and your components won't re-render. If you want mutation-style writes, use the immer middleware (Section 5.6).

Q: How do I test a store?

Import the store, call .getState() to read, .setState(...) to seed, .subscribe(...) to spy on changes, and reset between tests by capturing the initial state and calling setState(initial, true). The project's own tests/vanilla/basic.test.ts is a tight reference.

Q: Where do I report bugs?

github.com/pmndrs/zustand/issues. Real-world referenced issues in this guide include #100 (sentiment), #2659 (help wanted) and #3437 (the import.meta.env Metro bundler bug).