Use shipped middleware around one store creator when the store needs persistence, Redux DevTools inspection, Immer updates, inferred slices, or Redux-style dispatching.
When to use this
Compose middleware at store creation time. The outer middleware receives the state creator returned by the inner middleware, so the order is part of the store's type and behavior. A practical React store can persist its state, label updates in Redux DevTools, and update nested data with Immer without adding a provider.
Install the core package and the optional middleware dependencies:
bashnpm install zustand immer @redux-devtools/extension
The Redux DevTools browser extension is also required for the DevTools connection.
Compose a React store
Define the store once, then render a component that selects the state and action it needs. This example shows the difference composition makes: one mounted todo list gets persistence, Redux DevTools action labels, and draft-style updates from a single store. For the typed store setup and the individual API roles, see Typed store patterns. The component renders a todo list; clicking Add todo appends a todo, and clicking a todo toggles its completion.
tsximport { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'
type Todo = {
text: string
done: boolean
}
type TodoStore = {
todos: Todo[]
addTodo: (text: string) => void
toggleTodo: (index: number) => void
}
const useTodoStore = create<TodoStore>()(
devtools(
persist(
immer((set) => ({
todos: [{ text: 'Read the store', done: false }],
addTodo: (text) =>
set((state) => {
state.todos.push({ text, done: false })
}, false, 'todos/add'),
toggleTodo: (index) =>
set((state) => {
const todo = state.todos[index]
if (todo) todo.done = !todo.done
}, false, 'todos/toggle'),
})),
{ name: 'todo-store' },
),
{ name: 'TodoStore' },
),
)
function TodoList() {
const todos = useTodoStore((state) => state.todos)
const addTodo = useTodoStore((state) => state.addTodo)
return (
<main>
<h1>Todos</h1>
<button onClick={() => addTodo('Try another update')}>Add todo</button>
<ul>
{todos.map((todo, index) => (
<li key={`${todo.text}-${index}`}>
<button
type="button"
onClick={() => useTodoStore.getState().toggleTodo(index)}
>
{todo.done ? 'Done' : 'Open'}: {todo.text}
</button>
</li>
))}
</ul>
</main>
)
}
const root = document.getElementById('root')!
createRoot(root).render(
<StrictMode>
<TodoList />
</StrictMode>,
)
immer makes the updater receive a draft, so push and property assignment produce immutable state updates. persist stores the serializable state under todo-store and rehydrates it when the store starts. devtools connects the store named TodoStore; the third argument passed to set labels each update as todos/add or todos/toggle. The mounted component displays the current list, and selecting todos makes it re-render when that list changes.
The order above follows the repository's typed composition pattern: devtools(persist(immer(...))). Keep the action label's second argument as false when supplying the third argument; that preserves the normal merge behavior.
Add inferred state with combine
Use combine when an initial object and the actions that extend it are easier to read than a repeated state type. It returns a state creator, so pass it directly to create:
tsximport { create } from 'zustand'
import { combine } from 'zustand/middleware'
const useCounterStore = create(
combine({ count: 0 }, (set, get) => ({
increment: () => set({ count: get().count + 1 }),
})),
)
export function Counter() {
const count = useCounterStore((state) => state.count)
const increment = useCounterStore((state) => state.increment)
return <button onClick={increment}>Count: {count}</button>
}
The returned hook has inferred count and increment fields. combine is an alternative to writing the store type explicitly; it does not replace the middleware composition used when the store also needs persistence or inspection.
Use a reducer and dispatch
Use redux when updates already fit a reducer and action objects. The middleware places dispatch in the store, so a component still selects from a Zustand hook:
tsximport { create } from 'zustand'
import { createRoot } from 'react-dom/client'
import { redux } from 'zustand/middleware'
type CounterState = { count: number }
type CounterAction =
| { type: 'increment' }
| { type: 'decrement' }
const counterReducer = (
state: CounterState,
action: CounterAction,
): CounterState => {
if (action.type === 'increment') return { count: state.count + 1 }
return { count: state.count - 1 }
}
const useReduxCounter = create(
redux(counterReducer, { count: 0 }),
)
function ReduxCounter() {
const count = useReduxCounter((state) => state.count)
const dispatch = useReduxCounter((state) => state.dispatch)
return (
<button onClick={() => dispatch({ type: 'increment' })}>
Count: {count}
</button>
)
}
document.body.innerHTML = '<div id="root"></div>'
const root = document.getElementById('root')!
createRoot(root).render(<ReduxCounter />)
The button dispatches an action, and the reducer returns the next complete CounterState. Wrap this creator with devtools when you also want action inspection.
Observe selected changes outside React
Use subscribeWithSelector when a vanilla subscription needs a selector rather than every state change. Its listener receives the selected value and its previous value:
tsimport { createStore } from 'zustand'
import { subscribeWithSelector } from 'zustand/middleware'
const store = createStore(
subscribeWithSelector(() => ({ status: 'idle' as 'idle' | 'ready' })),
)
const unsubscribe = store.subscribe(
(state) => state.status,
(status, previousStatus) => {
console.log(previousStatus, '->', status)
},
)
store.setState({ status: 'ready' })
unsubscribe()
The listener runs when the selected status changes, not for unrelated fields. Supply fireImmediately: true or an equalityFn in the third argument when that subscription needs those semantics.
Options that matter
| Option | Type | Default | What it does |
|---|---|---|---|
persist.name | string | — | Names the stored value; use a unique name for each persisted store. |
persist.storage | PersistStorage | JSON storage backed by window.localStorage | Selects the persistence engine. |
persist.partialize | (state) => persistedState | — | Filters the state written to storage. |
persist.skipHydration | boolean | false | Prevents hydration during initialization so the application can call rehydrate() at a controlled point. |
devtools.name | string | — | Names the connection in Redux DevTools. |
devtools.enabled | boolean | development: true, production: false | Enables or disables the DevTools integration. |
devtools.anonymousActionType | string | inferred action type or anonymous | Names mutations that do not provide an action type. |
For a nonstandard storage engine, use createJSONStorage with a StateStorage implementation. The storage option accepts PersistStorage. If persisted storage is asynchronous, hydration happens after the initial render; use the hydration controls described in Persist store data. Persist's default merge is shallow, so use Merge persisted state for nested persisted data that needs a deep merge.
Pitfalls
- Add
immerand@redux-devtools/extensionwhen using those middleware;zustandalone does not install either dependency. - Keep middleware around the rendered store creator. A standalone middleware call does not update a store that a component renders.
- Give each persisted store a unique
name; the value identifies its storage entry. - Use
useShallowor a stable selector when a selector returns a new reference on every render. See Selectors and rendering.
Live demo
See the Zustand live demo for a running store-driven interface.
Was this page helpful?