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:
- Immer processes the draft mutation first
- The resulting immutable state flows outward to devtools for time-travel debugging
- 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
immermiddleware insrc/middleware/immer.tsinterceptssetStateand runs functional updaters through Immer'sproducemethod. - Syntax: Write
set(draft => { draft.property = value })instead of manual object spreading. - Composition: Wrap
immerclosest to the store creator when chaining withdevtoolsorpersistto 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →