How to Use Immer with Zustand Middleware for Immutable State Updates

Zustand's immer middleware enables "mutative" draft functions in your store actions by wrapping setState with Immer's produce method, automatically generating immutable updates while preserving reference equality and change detection.

The pmndrs/zustand repository ships a dedicated immer middleware that eliminates the need for manual spread operators when updating nested objects. By intercepting the set function in src/vanilla.ts and processing functional updaters through Immer, this middleware lets you write direct property assignments that compile to immutable state transitions. This guide explains the internal architecture defined in src/middleware/immer.ts and demonstrates proper integration patterns for vanilla and React environments.

Architecture of the Immer Middleware

Zustand's middleware system relies on higher-order functions that transform the state creator before it reaches the core store implementation.

The State Creator Pattern

At the lowest level, Zustand stores are initialized by a creator function that receives the raw setState, getState, and the store API:

(set, get, store) => ({ /* state & actions */ })

This initializer is processed by createStore in src/vanilla.ts (lines 66-84), which provides the base setState implementation that the middleware will decorate.

The immerImpl Wrapper

The immer middleware, defined in src/middleware/immer.ts (lines 5-88), exports an immerImpl function that patches the store's setState method:

const immerImpl: ImmerImpl = (initializer) => (set, get, store) => {
  store.setState = (updater, replace, ...args) => {
    const nextState = typeof updater === 'function'
      ? produce(updater as any)               // ← Immer creates a draft
      : updater
    return set(nextState, replace as any, ...args)
  }
  return initializer(store.setState, get, store)
}

When your action calls set(draft => { draft.count++ }), the middleware detects that the updater is a function, passes it to produce, and forwards the resulting immutable state to Zustand's core setter. Plain values (non-functions) pass through unchanged.

TypeScript Augmentation

The same file (lines 21-68) provides WithImmer and StoreImmer utility types that augment the store's setState signature. This allows TypeScript to recognize when you're passing a draft-mutating function versus a partial state object, enabling full type safety for nested mutations.

Implementing Immer in Your Store

Basic Vanilla Store Setup

For non-React environments, import createStore from the vanilla entry point and wrap your initializer with immer:

import { createStore } from 'zustand/vanilla'
import { immer } from 'zustand/middleware/immer'

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

export const counterStore = createStore<CounterState>()(
  immer((set) => ({
    count: 0,
    inc: () => set((state) => { state.count += 1 }),
    dec: () => set((state) => { state.count -= 1 }),
    add: (n) => set((state) => { state.count += n })
  }))
)

Note that set now accepts a function receiving a mutable draft of your state.

React Integration with Devtools

When using React, compose immer with other middlewares like devtools. According to the advanced TypeScript guide in docs/learn/guides/advanced-typescript.md (line 226), place immer closest to the store to prevent other middlewares from overwriting the patched setState:

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

type Todo = { id: number; text: string; done: boolean }

type TodoStore = {
  todos: Todo[]
  add: (text: string) => void
  toggle: (id: number) => void
}

export const useTodoStore = create<TodoStore>()(
  devtools(
    immer((set) => ({
      todos: [],
      add: (text) =>
        set((state) => {
          state.todos.push({ id: Date.now(), text, done: false })
        }),
      toggle: (id) =>
        set((state) => {
          const todo = state.todos.find((t) => t.id === id)
          if (todo) todo.done = !todo.done
        })
    }))
  )
)

Persistence with Immer

The immer middleware composes cleanly with persist for state hydration. Wrap immer inside persist to ensure mutations are processed before serialization:

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

type ThemeStore = {
  dark: boolean
  toggle: () => void
}

export const useThemeStore = create<ThemeStore>()(
  persist(
    immer((set) => ({
      dark: false,
      toggle: () => set((state) => { state.dark = !state.dark })
    })),
    { name: 'theme-storage' }
  )
)

TypeScript Configuration

For strict typing, the middleware augments the StoreMutators interface. The generated setState signature accepts either a partial state object or a draft mutator:

import { StoreApi } from 'zustand/vanilla'
import { immer } from 'zustand/middleware/immer'

// The immer middleware automatically updates the setState type to:
type ImmerSetState<T> = (
  updater: ((draft: T) => void) | Partial<T>,
  replace?: boolean,
  ...args: any[]
) => void

This type inference is handled internally by the WithImmer type definition in src/middleware/immer.ts.

Best Practices for Middleware Ordering

Middleware wrapping order determines which layer intercepts setState first. Since immer patches store.setState directly, wrapping it with devtools (as devtools(immer(...))) ensures that:

  1. Immer processes the draft mutation first
  2. The resulting immutable state flows outward to devtools for time-travel debugging
  3. Subsequent middlewares receive the finalized state object

Placing immer as the innermost wrapper guarantees that the draft-to-immutable conversion happens before any logging or persistence layer examines the update.

Summary

  • Core mechanism: The immer middleware in src/middleware/immer.ts intercepts setState and runs functional updaters through Immer's produce method.
  • Syntax: Write set(draft => { draft.property = value }) instead of manual object spreading.
  • Composition: Wrap immer closest to the store creator when chaining with devtools or persist to preserve the draft functionality.
  • Type safety: The middleware augments TypeScript definitions to recognize draft functions as valid setters.

Frequently Asked Questions

What is the correct way to update arrays with Zustand Immer middleware?

Use standard mutable array methods like push, splice, or direct index assignment inside the draft function. The middleware translates these mutations into immutable updates. For example: set(state => { state.items.push(newItem) }) safely appends to your array without manual copying.

Can I use the Immer middleware with the vanilla createStore API?

Yes. Import createStore from zustand/vanilla and apply immer exactly as you would with the React create function. The middleware operates at the store level, independent of UI bindings, making it compatible with any Zustand consumer including Vue, Svelte, or Node.js scripts.

Why does middleware order matter when using devtools with immer?

The immer middleware replaces the store.setState reference with a wrapper that calls produce. If you wrap devtools inside immer (i.e., immer(devtools(...))), the devtools layer replaces setState after Immer has patched it, potentially stripping the draft-processing behavior. Always use devtools(immer(...)) to ensure Immer's wrapper remains active.

Does the Immer middleware handle non-function set calls?

Yes. The wrapper in src/middleware/immer.ts checks typeof updater === 'function'. If you pass a plain object like set({ count: 5 }), it bypasses produce and forwards the value directly to the core setState, maintaining backward compatibility with standard Zustand usage patterns.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →