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 imperativeStoreApi. - Selector-based subscriptions: components only re-render when the selected
slice changes (
Object.iscomparison 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 experimentalunstable_ssrSafe. - TypeScript-first: middleware types compose through a
StoreMutatorsregistry sosetStateoverloads stay accurate after stacking middleware. - Zero dependencies for core;
use-sync-external-storeis 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/vanilladoes 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/middlewareis just a file inside the same npm tarball.
2.4 Project setup checklist
- [x]
zustandis added todependencies(notdevDependencies). - [x] React (or your framework) is already installed.
- [x] You set
"strict": trueintsconfig.jsonif 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 formcreate<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 likeuseBearStore((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
setproduces a new state value that isObject.is-equal to the current state, listeners are NOT called. SettingNaNtoNaNis a no-op for subscribers. Setting state tonullworks 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: falsein 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
createWithEqualityFnexists for backward compatibility with Zustand v3's equality semantics and for users who prefer per-selector equality without wrapping every selector inuseShallow. 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
storageoption defaults tocreateJSONStorage(() => 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
immerpackage is a peer dependency. Runnpm install immerbefore importing fromzustand/middleware/immer. The peerDependencies list requiresimmer >= 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:
- Move the offending
setinto auseEffectso it only runs on the client. - If the write is intentionally server-side (rare), don't use
ssrSafe. - 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).