Zustand Immer
Write mutable-style state updates in Zustand stores with the Immer middleware for cleaner nested mutations
When to Use
- Store has deeply nested state that requires verbose spread operators to update
- Updating items in arrays by index or by ID lookup
- Teams familiar with Redux Toolkit's Immer integration wanting the same ergonomics
- Any update where the immutable spread version is hard to read or error-prone
Instructions
- Wrap the store creator with the
immermiddleware fromzustand/middleware/immer. - Inside
set, mutate thestateobject directly — Immer produces a new immutable state behind the scenes. - Do NOT return a value from the
setcallback when using Immer — just mutate. - The
immermiddleware should be the innermost middleware when combined with others. - Install
immeras a peer dependency:npm install immer.
// stores/kanban-store.ts
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
interface Card {
id: string;
title: string;
description: string;
}
interface Column {
id: string;
title: string;
cards: Card[];
}
interface KanbanStore {
columns: Column[];
addCard: (columnId: string, card: Card) => void;
moveCard: (fromCol: string, toCol: string, cardId: string) => void;
updateCard: (columnId: string, cardId: string, updates: Partial<Card>) => void;
removeCard: (columnId: string, cardId: string) => void;
}
export const useKanbanStore = create<KanbanStore>()(
immer((set) => ({
columns: [],
addCard: (columnId, card) =>
set((state) => {
const column = state.columns.find((c) => c.id === columnId);
if (column) column.cards.push(card);
}),
moveCard: (fromCol, toCol, cardId) =>
set((state) => {
const source = state.columns.find((c) => c.id === fromCol);
const target = state.columns.find((c) => c.id === toCol);
if (!source || !target) return;
const cardIndex = source.cards.findIndex((c) => c.id === cardId);
if (cardIndex === -1) return;
const [card] = source.cards.splice(cardIndex, 1);
target.cards.push(card);
}),
updateCard: (columnId, cardId, updates) =>
set((state) => {
const column = state.columns.find((c) => c.id === columnId);
const card = column?.cards.find((c) => c.id === cardId);
if (card) Object.assign(card, updates);
}),
removeCard: (columnId, cardId) =>
set((state) => {
const column = state.columns.find((c) => c.id === columnId);
if (column) {
column.cards = column.cards.filter((c) => c.id !== cardId);
}
}),
}))
);
Details
Without Immer (comparison): The moveCard action without Immer:
moveCard: (fromCol, toCol, cardId) =>
set((state) => ({
columns: state.columns.map((col) => {
if (col.id === fromCol) {
return { ...col, cards: col.cards.filter((c) => c.id !== cardId) };
}
if (col.id === toCol) {
const card = state.columns.find((c) => c.id === fromCol)!.cards.find((c) => c.id === cardId)!;
return { ...col, cards: [...col.cards, card] };
}
return col;
}),
})),
Middleware stacking with Immer:
create<Store>()(
devtools(
persist(
immer((set) => ({
/* ... */
})),
{ name: 'key' }
),
{ name: 'Store' }
)
);
// Order: devtools(persist(immer(...)))
Immer rules:
- Either mutate state OR return a new value — never both
- Do not destructure state at the top level (
const { items } = state; items.push(x)— this works but is confusing; preferstate.items.push(x)) - Immer cannot track mutations to
Map,Set, orDateobjects unless you enable MapSet plugin - Immer adds ~5KB to the bundle — only use it when the readability benefit justifies it
When NOT to use Immer: For flat state or simple updates (set({ count: state.count + 1 })), Immer adds overhead without readability benefit. Use it specifically for nested mutations.
Source
https://zustand.docs.pmnd.rs/middlewares/immer
Process
- Read the instructions and examples in this document.
- Apply the patterns to your implementation, adapting to your specific context.
- Verify your implementation against the details and edge cases listed above.
Harness Integration
- Type: knowledge — this skill is a reference document, not a procedural workflow.
- No tools or state — consumed as context by other skills and agents.
Success Criteria
- The patterns described in this document are applied correctly in the implementation.
- Edge cases and anti-patterns listed in this document are avoided.