Immer makes immutable updates feel like plain mutations — produce(state, draft => { draft.user.name = "Alice" }) returns a new immutable object with structural sharing. Under the hood Immer uses Proxies to record mutations on the draft, then creates a minimally changed copy. useImmer is the React hook equivalent of useState with a draft updater. useImmerReducer replaces useReducer with draft-based reducers. enablePatches() activates the patches plugin — produceWithPatches returns [nextState, patches, inversePatches] for undo/redo. createDraft + finishDraft gives manual lifecycle control. Redux Toolkit’s createSlice uses Immer internally — slice reducers can mutate state directly. Curried producers const addItem = produce((draft, item) => { draft.items.push(item) }) create reusable updaters. Claude Code generates Immer produce recipes, useImmer hooks, patch-based undo/redo, and Redux Toolkit slices with Immer mutations for complex nested state.
CLAUDE.md for Immer
## Immer Stack
- Version: immer >= 10.0
- Produce: const next = produce(state, draft => { draft.x = 1 }) — immutable update
- Hook: const [state, updateState] = useImmer(initial) — setState with draft
- Reducer: const [state, dispatch] = useImmerReducer(reducer, initial) — draft reducer
- Patches: enablePatches() once at app init — produceWithPatches for undo/redo
- Redux: RTK createSlice reducers already use Immer — mutate state directly
- Curried: const updater = produce((draft, arg) => {...}) — reusable recipe
- Freeze: immer.setAutoFreeze(true) — default in dev, disable for perf in prod if needed
Core Produce Patterns
// lib/state-utils.ts — produce recipes
import { produce, enablePatches, produceWithPatches, applyPatches, type Draft } from "immer"
enablePatches() // Call once at app startup
interface CartItem {
productId: string
name: string
priceCents: number
quantity: number
}
interface CartState {
items: CartItem[]
couponCode: string | null
notes: string
}
// Simple nested update — traditional approach requires spreading every level
function addToCartManual(state: CartState, item: CartItem): CartState {
const existing = state.items.find(i => i.productId === item.productId)
if (existing) {
return {
...state,
items: state.items.map(i =>
i.productId === item.productId ? { ...i, quantity: i.quantity + 1 } : i
),
}
}
return { ...state, items: [...state.items, item] }
}
// Same thing with Immer — reads like mutation, produces immutable result
const addToCartImmer = produce((draft: Draft<CartState>, item: CartItem) => {
const existing = draft.items.find(i => i.productId === item.productId)
if (existing) {
existing.quantity += 1
} else {
draft.items.push(item)
}
})
const removeFromCart = produce((draft: Draft<CartState>, productId: string) => {
const index = draft.items.findIndex(i => i.productId === productId)
if (index !== -1) draft.items.splice(index, 1)
})
const updateQuantity = produce(
(draft: Draft<CartState>, productId: string, quantity: number) => {
const item = draft.items.find(i => i.productId === productId)
if (item) {
if (quantity <= 0) {
draft.items.splice(draft.items.indexOf(item), 1)
} else {
item.quantity = quantity
}
}
}
)
const applyCoupon = produce((draft: Draft<CartState>, code: string) => {
draft.couponCode = code
})
const clearCart = produce((draft: Draft<CartState>) => {
draft.items = []
draft.couponCode = null
draft.notes = ""
})
export const cartUpdaters = {
addToCart: addToCartImmer,
removeFromCart,
updateQuantity,
applyCoupon,
clearCart,
}
useImmer React Hook
// components/CartManager.tsx — useImmer for component state
"use client"
import { useImmer } from "use-immer"
interface OrderFormState {
items: { productId: string; name: string; quantity: number; priceCents: number }[]
shipping: {
firstName: string
lastName: string
address: string
city: string
state: string
zip: string
country: string
}
billing: {
sameAsShipping: boolean
address?: string
city?: string
zip?: string
}
notes: string
giftMessage: string | null
}
export function OrderForm() {
const [form, updateForm] = useImmer<OrderFormState>({
items: [],
shipping: { firstName: "", lastName: "", address: "", city: "", state: "", zip: "", country: "US" },
billing: { sameAsShipping: true },
notes: "",
giftMessage: null,
})
// Deep nested update — no spreading needed
function updateShippingField(field: keyof OrderFormState["shipping"], value: string) {
updateForm(draft => {
draft.shipping[field] = value
})
}
function toggleBillingSameAsShipping() {
updateForm(draft => {
draft.billing.sameAsShipping = !draft.billing.sameAsShipping
if (draft.billing.sameAsShipping) {
// Clear separate billing when toggling back
delete draft.billing.address
delete draft.billing.city
delete draft.billing.zip
}
})
}
function addItem(item: OrderFormState["items"][0]) {
updateForm(draft => {
const existing = draft.items.find(i => i.productId === item.productId)
if (existing) {
existing.quantity++
} else {
draft.items.push(item)
}
})
}
function removeItem(productId: string) {
updateForm(draft => {
draft.items = draft.items.filter(i => i.productId !== productId)
})
}
const total = form.items.reduce((sum, i) => sum + i.priceCents * i.quantity, 0)
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<input
value={form.shipping.firstName}
onChange={e => updateShippingField("firstName", e.target.value)}
placeholder="First name"
className="border rounded px-3 py-2"
/>
<input
value={form.shipping.lastName}
onChange={e => updateShippingField("lastName", e.target.value)}
placeholder="Last name"
className="border rounded px-3 py-2"
/>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.billing.sameAsShipping}
onChange={toggleBillingSameAsShipping}
/>
Billing same as shipping
</label>
<div className="text-sm font-medium">
Total: ${(total / 100).toFixed(2)} ({form.items.length} items)
</div>
</div>
)
}
Patch-Based Undo/Redo
// lib/undo-redo.ts — patches for history
import { enablePatches, produceWithPatches, applyPatches, type Patch } from "immer"
enablePatches()
interface HistoryEntry {
patches: Patch[]
inversePatches: Patch[]
}
export function createUndoableState<S>(initialState: S) {
let current = initialState
const history: HistoryEntry[] = []
let cursor = -1 // Points to last applied patch
function apply(recipe: (draft: S) => void): S {
const [next, patches, inversePatches] = produceWithPatches(current, recipe)
if (patches.length === 0) return current // No change
// Discard future if we're mid-history
history.splice(cursor + 1)
history.push({ patches, inversePatches })
cursor = history.length - 1
current = next
return current
}
function undo(): S | null {
if (cursor < 0) return null
const { inversePatches } = history[cursor]!
current = applyPatches(current, inversePatches)
cursor--
return current
}
function redo(): S | null {
if (cursor >= history.length - 1) return null
cursor++
const { patches } = history[cursor]!
current = applyPatches(current, patches)
return current
}
return {
get state() { return current },
apply,
undo,
redo,
canUndo: () => cursor >= 0,
canRedo: () => cursor < history.length - 1,
}
}
// Usage in a React hook
import { useState, useCallback } from "react"
export function useUndoableImmer<S>(initialState: S) {
const [, forceUpdate] = useState(0)
const manager = useState(() => createUndoableState(initialState))[0]
const update = useCallback((recipe: (draft: S) => void) => {
manager.apply(recipe)
forceUpdate(n => n + 1)
}, [manager])
const undo = useCallback(() => {
manager.undo()
forceUpdate(n => n + 1)
}, [manager])
const redo = useCallback(() => {
manager.redo()
forceUpdate(n => n + 1)
}, [manager])
return {
state: manager.state,
update,
undo,
redo,
canUndo: manager.canUndo(),
canRedo: manager.canRedo(),
}
}
useImmerReducer
// reducers/order-reducer.ts — useImmerReducer
import { useImmerReducer } from "use-immer"
import type { Draft } from "immer"
interface OrderState {
status: "draft" | "pending" | "confirmed" | "shipped" | "delivered"
items: { id: string; name: string; quantity: number; priceCents: number }[]
discountCents: number
notes: string
}
type OrderAction =
| { type: "ADD_ITEM"; item: OrderState["items"][0] }
| { type: "REMOVE_ITEM"; id: string }
| { type: "SET_QUANTITY"; id: string; quantity: number }
| { type: "APPLY_DISCOUNT"; cents: number }
| { type: "UPDATE_STATUS"; status: OrderState["status"] }
| { type: "ADD_NOTE"; note: string }
function orderReducer(draft: Draft<OrderState>, action: OrderAction) {
switch (action.type) {
case "ADD_ITEM": {
const existing = draft.items.find(i => i.id === action.item.id)
if (existing) {
existing.quantity += action.item.quantity
} else {
draft.items.push(action.item)
}
break
}
case "REMOVE_ITEM":
draft.items = draft.items.filter(i => i.id !== action.id)
break
case "SET_QUANTITY": {
const item = draft.items.find(i => i.id === action.id)
if (item) item.quantity = Math.max(0, action.quantity)
draft.items = draft.items.filter(i => i.quantity > 0)
break
}
case "APPLY_DISCOUNT":
draft.discountCents = action.cents
break
case "UPDATE_STATUS":
draft.status = action.status
break
case "ADD_NOTE":
draft.notes = action.note
break
}
}
const initialOrderState: OrderState = {
status: "draft",
items: [],
discountCents: 0,
notes: "",
}
export function useOrderReducer() {
return useImmerReducer(orderReducer, initialOrderState)
}
For the Zustand alternative when global cross-component state is needed rather than component or reducer-local state — Zustand also supports Immer middleware (immer middleware) for mutable store updates while adding singleton store access patterns outside React, see the Zustand Advanced guide. For the structuredClone alternative for simple single-level updates where Immer’s proxy overhead is overkill — structuredClone deep-clones a value natively in modern runtimes without any library but produces a full copy with no structural sharing, see the performance guide for when to prefer native cloning. The Claude Skills 360 bundle includes Immer skill sets covering produce recipes, useImmer hooks, and patch-based undo/redo. Start with the free tier to try immutable state generation.