Skip to content
D
Documentation

Compose store middleware

how-to
3 min readUpdated

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:

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

tsx
import { 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:

tsx
import { 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:

tsx
import { 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:

ts
import { 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

OptionTypeDefaultWhat it does
persist.namestring—Names the stored value; use a unique name for each persisted store.
persist.storagePersistStorageJSON storage backed by window.localStorageSelects the persistence engine.
persist.partialize(state) => persistedState—Filters the state written to storage.
persist.skipHydrationbooleanfalsePrevents hydration during initialization so the application can call rehydrate() at a controlled point.
devtools.namestring—Names the connection in Redux DevTools.
devtools.enabledbooleandevelopment: true, production: falseEnables or disables the DevTools integration.
devtools.anonymousActionTypestringinferred action type or anonymousNames 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 immer and @redux-devtools/extension when using those middleware; zustand alone 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 useShallow or 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?