React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

zustandimmerimmutablemutations

Immer Middleware

Recipe

Use the immer middleware to write mutable-style state updates that produce immutable state under the hood. This eliminates manual object spreading for nested updates.

npm install immer
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
 
interface Todo {
  id: string;
  text: string;
  done: boolean;
  subtasks: { id: string; text: string; done: boolean }[];
}
 
interface TodoStore {
  todos: Todo[];
  addTodo: (text: string) => void;
  toggleTodo: (id: string) => void;
  addSubtask: (todoId: string, text: string) => void;
  toggleSubtask: (todoId: string, subtaskId: string) => void;
}
 
export const useTodoStore = create<TodoStore>()(
  immer((set) => ({
    todos: [],
 
    addTodo: (text) =>
      set((state) => {
        state.todos.push({
          id: crypto.randomUUID(),
          text,
          done: false,
          subtasks: [],
        });
      }),
 
    toggleTodo: (id) =>
      set((state) => {
        const todo = state.todos.find((t) => t.id === id);
        if (todo) todo.done = !todo.done;
      }),
 
    addSubtask: (todoId, text) =>
      set((state) => {
        const todo = state.todos.find((t) => t.id === todoId);
        if (todo) {
          todo.subtasks.push({ id: crypto.randomUUID(), text, done: false });
        }
      }),
 
    toggleSubtask: (todoId, subtaskId) =>
      set((state) => {
        const todo = state.todos.find((t) => t.id === todoId);
        const subtask = todo?.subtasks.find((s) => s.id === subtaskId);
        if (subtask) subtask.done = !subtask.done;
      }),
  }))
);

Working Example

// stores/spreadsheet-store.ts
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
import { devtools } from "zustand/middleware";
 
interface Cell {
  value: string;
  formula: string | null;
  format: {
    bold: boolean;
    italic: boolean;
    color: string;
    backgroundColor: string;
  };
}
 
interface SpreadsheetStore {
  cells: Record<string, Cell>;
  selectedCell: string | null;
  setCellValue: (cellId: string, value: string) => void;
  setCellFormula: (cellId: string, formula: string) => void;
  formatCell: (cellId: string, format: Partial<Cell["format"]>) => void;
  selectCell: (cellId: string | null) => void;
  batchUpdate: (updates: { cellId: string; value: string }[]) => void;
  clearCell: (cellId: string) => void;
}
 
const defaultCell: Cell = {
  value: "",
  formula: null,
  format: { bold: false, italic: false, color: "#000000", backgroundColor: "#ffffff" },
};
 
export const useSpreadsheetStore = create<SpreadsheetStore>()(
  devtools(
    immer((set) => ({
      cells: {},
      selectedCell: null,
 
      setCellValue: (cellId, value) =>
        set((state) => {
          if (!state.cells[cellId]) {
            state.cells[cellId] = { ...defaultCell };
          }
          state.cells[cellId].value = value;
          state.cells[cellId].formula = null;
        }),
 
      setCellFormula: (cellId, formula) =>
        set((state) => {
          if (!state.cells[cellId]) {
            state.cells[cellId] = { ...defaultCell };
          }
          state.cells[cellId].formula = formula;
        }),
 
      formatCell: (cellId, format) =>
        set((state) => {
          if (!state.cells[cellId]) {
            state.cells[cellId] = { ...defaultCell };
          }
          Object.assign(state.cells[cellId].format, format);
        }),
 
      selectCell: (cellId) =>
        set((state) => {
          state.selectedCell = cellId;
        }),
 
      batchUpdate: (updates) =>
        set((state) => {
          for (const { cellId, value } of updates) {
            if (!state.cells[cellId]) {
              state.cells[cellId] = { ...defaultCell };
            }
            state.cells[cellId].value = value;
          }
        }),
 
      clearCell: (cellId) =>
        set((state) => {
          delete state.cells[cellId];
        }),
    })),
    { name: "SpreadsheetStore" }
  )
);
// components/cell.tsx
"use client";
 
import { useSpreadsheetStore } from "@/stores/spreadsheet-store";
 
export function Cell({ cellId }: { cellId: string }) {
  const cell = useSpreadsheetStore((s) => s.cells[cellId]);
  const selectedCell = useSpreadsheetStore((s) => s.selectedCell);
  const setCellValue = useSpreadsheetStore((s) => s.setCellValue);
  const selectCell = useSpreadsheetStore((s) => s.selectCell);
 
  const isSelected = selectedCell === cellId;
 
  return (
    <td
      className={isSelected ? "border-blue-500 border-2" : "border"}
      onClick={() => selectCell(cellId)}
    >
      {isSelected ? (
        <input
          autoFocus
          value={cell?.value ?? ""}
          onChange={(e) => setCellValue(cellId, e.target.value)}
          style={{
            fontWeight: cell?.format.bold ? "bold" : "normal",
            fontStyle: cell?.format.italic ? "italic" : "normal",
            color: cell?.format.color,
          }}
        />
      ) : (
        <span>{cell?.value ?? ""}</span>
      )}
    </td>
  );
}

Deep Dive

How It Works

  • The immer middleware wraps the set function. When you call set((state) => { ... }), immer creates a draft proxy of the current state.
  • You mutate the draft directly (push, splice, delete, assign). Immer tracks all changes and produces a new immutable state object.
  • Only changed parts of the state tree get new references. Unchanged branches keep their original references, preserving React's memoization.
  • Immer uses ES Proxy internally. The draft looks and behaves like a mutable object but never modifies the actual state.
  • After the set callback returns, immer finalizes the draft into a new frozen state and Zustand triggers a re-render.

Variations

Without immer (manual spreading):

// This is what you would write without immer
formatCell: (cellId, format) =>
  set((state) => ({
    cells: {
      ...state.cells,
      [cellId]: {
        ...state.cells[cellId],
        format: {
          ...state.cells[cellId].format,
          ...format,
        },
      },
    },
  })),

Immer with persist:

create<Store>()(
  persist(
    immer((set) => ({ ... })),
    { name: "store" }
  )
);

Returning new state instead of mutating:

// You can also return a new value from set with immer
set((state) => {
  // Return replaces state (same as without immer)
  return { count: state.count + 1 };
});

TypeScript Notes

  • Immer middleware preserves all store types. No additional typing is needed.
  • Inside set, the state parameter is typed as Draft<State>, which makes all properties mutable.
  • Use create<State>()(immer(...)) with double invocation for correct type inference.
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
 
// Types work identically with or without immer
export const useStore = create<MyState>()(
  immer((set) => ({
    nested: { deep: { value: 0 } },
    update: () =>
      set((state) => {
        state.nested.deep.value = 42; // Fully typed
      }),
  }))
);

Gotchas

  • immer must be the innermost middleware (closest to the store creator). Placing it outside persist or devtools will not work correctly.
  • Do not return a value AND mutate the draft in the same set callback. Either mutate or return, not both. Immer will throw if you do both.
  • Immer adds ~6KB gzipped to your bundle. If your state is flat and simple, manual spreading may be preferable.
  • Immer cannot proxy certain types: Maps, Sets, and classes are not supported in Immer v9 without enableMapSet(). Call it once at your app's entry point if needed.
  • The state inside set is a Proxy draft. Do not pass it to async functions or store it for later use. It becomes invalid after set returns.
  • Object.keys() and for...in work on drafts, but some edge cases with Array.isArray() or instanceof checks may behave unexpectedly on draft proxies.

Alternatives

ApproachProsCons
Immer middlewareClean nested updates, familiar syntaxBundle size, Proxy overhead
Manual spreadingNo dependencies, full controlVerbose for deeply nested state
structuredClone + mutateNo library neededClones entire state, no structural sharing
Flat state designNo deep nesting to worry aboutMay require normalization

FAQs

What problem does the immer middleware solve?
  • It eliminates manual object spreading for nested state updates.
  • You write mutable-style code (state.count += 1) that produces immutable state under the hood.
  • Deeply nested updates that require 3-4 levels of spreading become one-liners.
Do you need to install immer separately?
  • Yes. Run npm install immer. The Zustand immer middleware imports from zustand/middleware/immer but depends on the immer package.
How does immer produce immutable state from mutable code?
  • Immer creates a Proxy draft of the current state.
  • You mutate the draft directly (push, splice, delete, assign).
  • After the set callback returns, immer finalizes the draft into a new frozen state object.
  • Only changed branches get new references; unchanged branches keep original references.
Where should immer go in the middleware stack?
  • Always innermost (closest to the store creator).
  • Example: devtools(persist(immer((set) => ({ ... })))).
Gotcha: Can you both mutate the draft AND return a new value in the same set callback?
  • No. Immer will throw an error if you do both.
  • Either mutate the draft or return a new value, never both.
Gotcha: Can you pass the draft state to an async function or store it for later?
  • No. The draft proxy becomes invalid after set returns.
  • Do not pass it to async functions, store it in a variable, or use it outside the set callback.
How much does immer add to the bundle size?
  • Approximately 6KB gzipped.
  • If your state is flat and simple, manual spreading may be preferable to avoid the extra dependency.
Does immer support Maps and Sets?
  • Not by default in Immer v9. You must call enableMapSet() once at your app's entry point.
  • Classes are also not supported without explicit configuration.
How is the state parameter typed inside set when using immer in TypeScript?
  • The state parameter is typed as Draft<State>, which makes all properties mutable.
  • No additional typing is needed -- immer middleware preserves all store types.
export const useStore = create<MyState>()(
  immer((set) => ({
    nested: { deep: { value: 0 } },
    update: () => set((state) => {
      state.nested.deep.value = 42; // Fully typed
    }),
  }))
);
Do you need the double invocation create<State>()() with immer?
  • Yes. Use create<State>()(immer(...)) for correct TypeScript inference.
  • This is the same pattern required for all Zustand middleware.
What does the equivalent code look like without immer for a deeply nested update?
// Without immer (manual spreading)
formatCell: (cellId, format) =>
  set((state) => ({
    cells: {
      ...state.cells,
      [cellId]: {
        ...state.cells[cellId],
        format: { ...state.cells[cellId].format, ...format },
      },
    },
  })),
 
// With immer
formatCell: (cellId, format) =>
  set((state) => {
    Object.assign(state.cells[cellId].format, format);
  }),