Immutable Updates with Immer: produce, Curried Producers, Patches and Redux
Key takeaways
Immer lets you work with immutable state by using mutable-style code. It's based on copy-on-write and is used by Redux Toolkit, React's useImmer, and many other libraries.
Introduction
Immer simplifies working with immutable state. Instead of manually creating copies with spread operators, you write intuitive “mutating” code, and Immer produces an immutable result.
Immutability is a foundational rule in most modern JavaScript state-management systems. React relies on reference equality to decide whether a component needs to re-render; Redux relies on the same reference equality to know whether a reducer produced a new store. If you mutate an object in place, oldState === newState still evaluates to true, and the framework has no signal that anything changed. That is why the idiomatic (but painful) approach has always been to copy every level of an object that contains a change, while leaving every untouched branch exactly as it was — a discipline usually enforced with spread operators.
The problem is that the deeper your state tree gets, the more spread operators you need, and the more error-prone the code becomes. Forget one level, and you silently mutate shared state; get the order of properties wrong in an object spread, and you can accidentally revert a field to a stale value. Immer, created by Michel Weststrate (also the author of MobX), solves this by giving you a temporary, mutable “draft” of your state built on JavaScript Proxy objects. You mutate the draft using ordinary assignment and array methods, and Immer intercepts every read and write to record exactly which parts of the tree actually changed. When your updater function returns, Immer produces a brand-new, frozen object where only the modified branches are new — everything else is the exact same reference as the original state. This technique is commonly called “structural sharing,” and it is the same idea that persistent-data-structure libraries in Clojure and Scala use, just achieved through a Proxy trap instead of a tree data structure.
Without Immer
const state = {
user: {
name: 'Alice',
address: {
city: 'New York',
zipCode: '10001',
},
},
};
// Update nested value - hard to read!
const newState = {
...state,
user: {
...state.user,
address: {
...state.user.address,
city: 'San Francisco',
},
},
};
With Immer
import { produce } from 'immer';
const newState = produce(state, draft => {
draft.user.address.city = 'San Francisco';
});
// Much cleaner!
Notice what actually changed between the two versions: the logic is identical — set city to 'San Francisco' — but the manual version spends most of its lines re-declaring structure that never changes (name, zipCode, every other key at every level). That boilerplate is not just ugly; it is a liability. If state.user later gains a fourth field, the raw-JavaScript version silently keeps working because the spread operator copies whatever is there — but if someone instead used explicit property listing instead of a spread, adding a field would require remembering to update every copy site in the codebase. Immer’s produce function sidesteps the whole category of bug: you only ever write the path to the value you are changing, and Immer’s Proxy machinery works out the rest of the copying for you, correctly, every time.
Under the hood, produce(base, recipe) wraps base in a Proxy. Every property access on the draft you receive in the recipe function is intercepted: if you only read from a nested object, Immer returns another Proxy wrapping that nested object (so it can also intercept reads/writes further down); if you write to a property, Immer marks that node and every ancestor node up to the root as “modified” and creates a shallow copy of just that node. Once the recipe function returns, Immer walks the tree of Proxies, and for every node that was never touched, it keeps the original reference from base. Only the nodes on the path to your actual edit are ever cloned. That is the entire trick — no diffing algorithm, no deep clone, just intercepted mutation on a scratch copy.
Installation
npm install immer
Immer ships as a single dependency with no peer dependencies for its core API — the Proxy-based produce function works in any environment with native ES2015 Proxy support (all evergreen browsers, Node.js 6+, and virtually every deployment target you would realistically ship to in 2026). If you need to support genuinely legacy environments without Proxy — old embedded WebViews, for example — Immer also ships an ES5 fallback implementation that uses Object.defineProperty getters/setters instead, though it is slower and does not support Map/Set drafting. You opt into it automatically; Immer detects Proxy availability at runtime and falls back only when necessary, so in the common case you do not need to configure anything beyond installing the package.
Basic Usage
import { produce } from 'immer';
const baseState = {
count: 0,
items: ['apple', 'banana'],
};
const nextState = produce(baseState, draft => {
draft.count += 1;
draft.items.push('orange');
});
console.log(baseState.count); // 0 (unchanged)
console.log(nextState.count); // 1
console.log(nextState.items); // ['apple', 'banana', 'orange']
Two details in this example are worth dwelling on because they surprise developers new to Immer. First, baseState.count is unchanged after produce runs — the original object was never touched, only read from. Immer’s Proxy traps intercept the draft.count += 1 assignment and route it to a fresh shallow copy, never to baseState itself. This is what makes it safe to keep a reference to the “before” state around for comparisons, undo history, or debugging, even after you have produced the “after” state.
Second, notice that draft.items.push('orange') works exactly like a normal array mutation, not like [...draft.items, 'orange']. This is the core ergonomic win of Immer: array and object mutation methods that would normally corrupt shared state — push, pop, splice, direct index assignment, delete draft.someKey — are all safe to use on a draft, because the draft is not the real array; it is a Proxy standing in for it. Once you internalize that “the draft is disposable, the base is sacred,” the rest of Immer’s API becomes intuitive: anything you would normally avoid doing to shared state becomes exactly what you should do to a draft.
Array Operations
import { produce } from 'immer';
const todos = [
{ id: 1, text: 'Buy milk', done: false },
{ id: 2, text: 'Walk dog', done: false },
];
// Add item
const withNew = produce(todos, draft => {
draft.push({ id: 3, text: 'Do laundry', done: false });
});
// Remove item
const withRemoved = produce(todos, draft => {
const index = draft.findIndex(t => t.id === 2);
draft.splice(index, 1);
});
// Update item
const withUpdated = produce(todos, draft => {
const todo = draft.find(t => t.id === 1);
todo.done = true;
});
// Filter (returns new array)
const withFiltered = produce(todos, draft => {
return draft.filter(t => !t.done);
});
The first three examples here — push, splice-based removal, and find-then-mutate — all rely on the implicit-return form: the recipe function mutates draft and returns nothing, and Immer treats the final state of the draft as the result. The fourth example, filtering, is different and deliberately included to illustrate a rule that trips people up constantly: Array.prototype.filter (like map, reduce, and most other array methods that return a new array) does not mutate the draft in place — it returns a plain new array. Because Immer only tracks changes made to the draft itself, calling draft.filter(...) and discarding the result does nothing. You must explicitly return the filtered array from the recipe so Immer knows to use it as the new state instead of the (untouched) draft. This is the single most common “Immer isn’t working” bug reported by newcomers — they write draft.filter(...) as a statement instead of return draft.filter(...), and then wonder why nothing changed.
A related gotcha with find: draft.find(t => t.id === 1) returns a drafted sub-object, not the plain underlying object, as long as you access it from within the recipe. That is exactly why todo.done = true on the following line works — todo is itself a Proxy that Immer is watching. If you were to store that same reference outside the recipe function and mutate it later, you would either get a runtime error (Immer revokes drafts after the recipe finishes, in development) or silently do nothing (in production builds where revocation checks are skipped), because a draft’s proxy is only valid for the lifetime of its produce call.
React Integration
import { useState } from 'react';
import { produce } from 'immer';
function TodoApp() {
const [todos, setTodos] = useState([]);
const addTodo = (text) => {
setTodos(produce(draft => {
draft.push({ id: Date.now(), text, done: false });
}));
};
const toggleTodo = (id) => {
setTodos(produce(draft => {
const todo = draft.find(t => t.id === id);
todo.done = !todo.done;
}));
};
return (
<div>
{todos.map(todo => (
<div key={todo.id} onClick={() => toggleTodo(todo.id)}>
{todo.done ? '✅' : '⬜️'} {todo.text}
</div>
))}
</div>
);
}
Pay close attention to the two different call shapes in addTodo versus toggleTodo above. setTodos(produce(draft => { ... })) uses React’s functional updater form: setState accepts a function that receives the current state and returns the next state, and produce, when called with a single argument (just the recipe, no base state), returns exactly that kind of function. This is the “curried” form of produce, and it composes naturally with React’s useState setter, with Redux Toolkit’s reducer signature, and with anything else that expects an (oldState) => newState callback. Calling produce(recipe) without a base state does not run the recipe immediately — it returns a reusable function you can hand off to whatever needs to call it later. We cover this curried form in more depth in Section 5.
It is worth being explicit about why you should reach for setTodos(produce(draft => {...})) here rather than setTodos([...todos, newTodo]). With useState, React batches updates and may schedule multiple state transitions from the same render before actually applying them; reading a stale todos closure variable directly (instead of using the functional updater) is a classic source of “my second click didn’t register” bugs. Immer’s curried form forces you into the safe pattern by construction, because the draft it hands you is always derived from whatever the latest state happens to be at the moment React actually runs the updater — not whatever was captured in the closure when the event handler was defined.
useImmer Hook
npm install use-immer
import { useImmer } from 'use-immer';
function TodoApp() {
const [todos, updateTodos] = useImmer([]);
const addTodo = (text) => {
updateTodos(draft => {
draft.push({ id: Date.now(), text, done: false });
});
};
const toggleTodo = (id) => {
updateTodos(draft => {
const todo = draft.find(t => t.id === id);
todo.done = !todo.done;
});
};
return <div>...</div>;
}
use-immer is a thin companion package that wraps useState (and, separately, useReducer) so that every update you dispatch is automatically run through produce. The trade-off is subtle but real: with plain useImmer, the value returned by the hook — todos in the example above — is always the plain, unwrapped, already-immutable result; you never see a draft or a Proxy outside of the updater function you pass to updateTodos. This keeps your rendering code exactly as simple as it would be with useState, while your update code gets Immer’s ergonomics. Teams that adopt Immer broadly in a React codebase often standardize on useImmer for any state whose shape is an object or array with more than one level of nesting, and keep plain useState for primitives and flat shapes, where a spread operator is already trivial and pulling in a Proxy has no benefit.
Curried Produce
import { produce } from 'immer';
// Create reusable updater
const addTodo = produce((draft, text) => {
draft.push({ id: Date.now(), text, done: false });
});
const toggleTodo = produce((draft, id) => {
const todo = draft.find(t => t.id === id);
todo.done = !todo.done;
});
// Use
const todos = [];
const withNew = addTodo(todos, 'Buy milk');
const withToggled = toggleTodo(withNew, 1);
produce actually has three call signatures, and this section demonstrates the one most people encounter last but end up relying on most in real applications. produce(base, recipe) runs immediately and returns the next state — that’s what Sections 2 through 4 used directly. produce(recipe) (one argument) instead returns a new function with the signature (base, ...extraArgs) => nextState, where extraArgs are whatever additional parameters your recipe function declares after draft. This is exactly how Redux Toolkit’s createSlice reducers work internally (see Section 8): each reducer you write is effectively a curried recipe, called later with (state, action) when an action is dispatched.
The practical benefit here is reusability and testability. addTodo and toggleTodo, as defined above, are pure, named functions with a clear signature that you can unit-test in isolation, pass around, compose, or export from a module — as opposed to being anonymous inline callbacks scattered through your component tree. If you find yourself writing the same produce(state, draft => { ... }) shape repeatedly with only the outer state argument changing, that is usually a signal to extract it into a curried, named updater like the ones above.
Patches
import { produceWithPatches, applyPatches } from 'immer';
const baseState = { count: 0, items: [] };
const [nextState, patches, inversePatches] = produceWithPatches(baseState, draft => {
draft.count = 1;
draft.items.push('apple');
});
console.log(patches);
// [
// { op: 'replace', path: ['count'], value: 1 },
// { op: 'add', path: ['items', 0], value: 'apple' }
// ]
// Apply patches to another state
const anotherState = applyPatches(baseState, patches);
// Undo with inverse patches
const undone = applyPatches(nextState, inversePatches);
console.log(undone); // Same as baseState
Patches are Immer’s answer to a question that comes up constantly in collaborative and time-traveling applications: “what exactly changed, and can I describe that change as portable data instead of as a diff of two whole objects?” The patch format Immer generates follows the shape of RFC 6902 JSON Patch closely (though it is not a strict implementation) — each patch is a small, serializable object describing one operation (add, remove, or replace) at one path. Because patches are plain JSON, you can send them over a WebSocket to sync state between a server and multiple clients, log them for audit trails, or persist them to reconstruct history without storing full state snapshots at every step — snapshotting whole state trees gets expensive fast once your state is more than a few kilobytes, while a patch is typically a few dozen bytes.
The inversePatches returned alongside patches are what make undo/redo genuinely simple to implement correctly. Naively, you might think you could “undo” by just reapplying the previous full state object, and that does work — but it does not compose well with a history stack, and it throws away the opportunity to send only the delta over the wire. produceWithPatches computes both directions in a single pass over the draft: the forward patches say how to go from baseState to nextState, and the inverse patches say how to go back. Because both are computed at the same time the mutation happens, you never have to separately diff two states after the fact — which is important, because a generic deep-diff of two arbitrary objects is both slower and more ambiguous (should a shifted array index be recorded as a move, or as a remove-plus-add?) than recording the specific operations that were actually performed.
function useUndoable(initialState) {
const [state, setState] = useState(initialState);
const [history, setHistory] = useState([]);
const [index, setIndex] = useState(-1);
const update = (updater) => {
const [nextState, patches, inversePatches] = produceWithPatches(state, updater);
setState(nextState);
setHistory([...history.slice(0, index + 1), { patches, inversePatches }]);
setIndex(index + 1);
};
const undo = () => {
if (index < 0) return;
const { inversePatches } = history[index];
setState(applyPatches(state, inversePatches));
setIndex(index - 1);
};
const redo = () => {
if (index >= history.length - 1) return;
const { patches } = history[index + 1];
setState(applyPatches(state, patches));
setIndex(index + 1);
};
return [state, update, undo, redo];
}
This useUndoable hook is a realistic sketch of the pattern most rich text editors, drawing tools, and form builders use for their undo stacks. A few things to note if you adapt this for production use. First, setHistory([...history.slice(0, index + 1), ...]) deliberately truncates any “future” redo entries whenever a new edit happens after an undo — this mirrors the behavior every editor you have ever used exhibits: once you undo and then make a fresh edit, the old redo branch is discarded, because it no longer makes sense relative to the new timeline. Second, this simplified version keeps the entire patch history in memory for the lifetime of the component; for a long editing session (thousands of keystrokes in a text editor, for example) you would want to cap the history length or periodically collapse older patches into a single snapshot, since each patch array grows the memory footprint incrementally.
It is also worth calling out the third, less commonly used argument to produce: a patchListener callback, called as produce(base, recipe, patchListener). produceWithPatches is really just sugar over this — it internally supplies a listener that collects the patches and returns them alongside the state instead of requiring you to manage a side-channel callback yourself. Reach for produceWithPatches in application code; drop down to the raw patchListener form only if you need patches emitted as a side effect (for example, streaming them out over a socket as they are produced) rather than collected and returned.
Return Values
import { produce } from 'immer';
const state = { count: 0 };
// Implicit return (modify draft)
const result1 = produce(state, draft => {
draft.count = 1;
});
console.log(result1); // { count: 1 }
// Explicit return (replaces state)
const result2 = produce(state, draft => {
return { count: 2, newProp: true };
});
console.log(result2); // { count: 2, newProp: true }
// Return undefined = no changes
const result3 = produce(state, draft => {
if (draft.count > 10) {
draft.count = 0;
}
});
console.log(result3); // { count: 0 } (unchanged, same reference)
Immer’s rule for reconciling mutation and return values is strict and worth memorizing precisely, because getting it wrong produces confusing results rather than an obvious error in some cases: if your recipe function returns any value other than undefined, that returned value entirely replaces the draft, and any mutations you made to the draft in the same call are discarded. This is intentional — Immer needs an unambiguous rule to decide what “the result” is, and “an explicit return always wins” is easier to reason about than trying to merge a returned value with in-place mutations. result3 above demonstrates the flip side: when the recipe’s if branch is not taken, the function implicitly returns undefined, and Immer correctly interprets that as “no explicit replacement was intended, use whatever state the draft ended up in” — which, since nothing was mutated, is the literal original object, not just an equal copy. This reference-identity preservation matters enormously for React: a component wrapped in React.memo or reading from a useMemo with state in its dependency array will correctly skip re-rendering, because state === previousState is true, not just deeply equal.
One important exception to memorize: you cannot return a primitive value (a string, number, boolean, or null) from a recipe that also received a primitive-typed base state, because Immer cannot draft primitives — there is nothing to wrap in a Proxy. If your top-level state actually is a primitive (rare, but it happens with small pieces of local component state), you must always use the explicit-return form and never attempt to mutate a “draft” of it.
Redux Integration
Redux Toolkit uses Immer internally:
import { createSlice } from '@reduxjs/toolkit';
const todosSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
// Write "mutating" code - Immer handles immutability!
addTodo: (state, action) => {
state.push({ id: Date.now(), text: action.payload, done: false });
},
toggleTodo: (state, action) => {
const todo = state.find(t => t.id === action.payload);
todo.done = !todo.done;
},
},
});
This integration is arguably the single biggest reason Immer became a default dependency for millions of React applications rather than a niche utility. Before Redux Toolkit, idiomatic Redux reducers were required to be pure functions that never mutated their state argument — a rule that was easy to state but constantly violated by accident, especially by developers coming from an object-oriented background where state.push(...) feels completely natural. Redux Toolkit’s createSlice wraps every reducer you write in produce automatically, which means the “obviously correct-looking” mutating code in the example above is not a bug — it is exactly what you are supposed to write. Internally, state.push(...) inside a Redux Toolkit reducer is operating on an Immer draft, not on the real Redux store slice, and Immer’s produce call around your reducer is what turns that draft into the properly immutable next state that Redux’s store actually holds.
There is one Redux-specific nuance to be aware of: a Redux Toolkit reducer, like any Immer recipe, must either mutate the draft or return a new value — never both, and it also must not return undefined unless the base state argument is an object or array (returning undefined from a reducer whose state is a primitive, like a lone boolean or number slice, is treated by Redux Toolkit as “explicitly set state to undefined,” which is almost never what you want). This is precisely the “Don’t Mix Return and Mutation” rule from Section 10, restated in a Redux-specific context where getting it wrong can silently corrupt your entire application’s store rather than just one local component’s state.
Performance
import { produce, enableMapSet } from 'immer';
// Enable ES2015 Maps and Sets (opt-in)
enableMapSet();
const state = new Map([
['user1', { name: 'Alice', age: 30 }],
['user2', { name: 'Bob', age: 25 }],
]);
const nextState = produce(state, draft => {
draft.get('user1').age = 31;
});
// Efficient structural sharing
console.log(state.get('user2') === nextState.get('user2')); // true (reused)
Map and Set support is gated behind an explicit enableMapSet() call rather than being enabled by default, and the reason is a real performance and bundle-size trade-off rather than an oversight. Drafting Map/Set requires Immer to install a different, more complex Proxy handler than the one used for plain objects and arrays (because Map/Set expose their contents through methods like .get()/.set()/.has() rather than through property access, and a Proxy trap for property access does not automatically intercept method calls the way it does for plain property reads). The vast majority of applications never store Map or Set instances directly in application state — they use plain objects and arrays, which serialize to JSON cleanly and work with every devtools extension — so Immer keeps this code path opt-in to avoid shipping it to everyone. If you do reach for Map, it is usually because you need guaranteed insertion-order iteration combined with non-string keys, or because you are managing a large keyed collection where you want O(1) key lookups without the subtle foot-guns of using arbitrary strings as plain-object keys (prototype pollution via __proto__ being the most infamous one).
The console.log at the end of this example is the entire point of structural sharing made concrete: only user1’s entry was modified, so only the path down to user1 was copied — user2’s object is the literal same reference before and after produce ran. If you were selecting user2’s data in a memoized React selector or a Reselect-style derived-state library, that selector would correctly skip recomputation, because its input reference did not change. This is the property that makes Immer’s approach scale well even for very large, deeply nested state trees: the cost of an update is proportional to the depth of the change, not to the total size of the state.
Recipe return values, draft types and auto-freeze
Don’t mix return and mutation
// Bad: mixing mutation and return
produce(state, draft => {
draft.count = 1;
return { count: 2 }; // Which one?
});
// Good: mutation only
produce(state, draft => {
draft.count = 1;
});
// Good: return only
produce(state, draft => {
return { count: 2 };
});
This is worth restating separately from Section 7’s explanation because it is easy to write by accident: developers used to arrow functions with early returns sometimes add a return someOtherThing inside a conditional, forgetting that any executed return of a new value replaces the draft entirely, including every mutation made earlier in the same recipe. Immer throws a runtime error when a recipe both modifies the draft and returns a different value, so the bug shows up immediately instead of silently producing the wrong state.
Type the draft with Draft<T>
interface State {
count: number;
items: string[];
}
const state: State = { count: 0, items: [] };
const nextState = produce(state, (draft: Draft<State>) => {
draft.count = 1;
draft.items.push('apple');
// TypeScript will catch errors!
});
Immer’s Draft<T> type utility is what makes this type-safe rather than merely convenient. Applied to a type T, Draft<T> recursively strips readonly modifiers from every property, so that a State interface you have deliberately marked as readonly for use elsewhere in your codebase (to catch accidental mutation outside of produce) becomes fully mutable only inside the recipe function, where mutation is actually safe. Outside the recipe, TypeScript continues to enforce immutability on the original State type as normal. This two-faced typing — immutable everywhere except inside the one function where mutation is intentional and contained — is difficult to replicate by hand with plain TypeScript utility types, and is one of the more compelling reasons teams already using TypeScript strict mode adopt Immer specifically, beyond just the runtime ergonomics.
Keep auto-freeze on while developing
import { setAutoFreeze } from 'immer';
// Enable in development (default)
setAutoFreeze(true);
// Disable in production for performance
if (process.env.NODE_ENV === 'production') {
setAutoFreeze(false);
}
setAutoFreeze(true) (the default) causes Immer to call Object.freeze() recursively on every state tree it produces. This is a deliberate safety net: it converts the class of bug where some far-flung piece of code accidentally mutates state outside of a produce call — bypassing Immer entirely and reintroducing the exact reference-sharing bug Immer exists to prevent — from a silent, hard-to-trace data corruption into an immediate, loud TypeError: Cannot assign to read only property thrown at the exact line where the illegal mutation happened. That immediacy is extremely valuable during development, because the alternative is debugging a component that “sometimes” renders stale data for reasons that only become clear after tracing every consumer of that state object.
The trade-off is that Object.freeze() is not free — walking and freezing a large state tree on every single produce call adds measurable overhead, particularly for state with thousands of array entries or deeply nested structures updated on a hot path (a live-updating chart or a virtualized list, for example). Because the point of auto-freeze is to catch programmer mistakes during development, and a correctly-written application never performs illegal mutations in the first place, disabling it in production (where you have presumably already caught those mistakes via testing and development-mode freezing) recovers that overhead with no behavior change for correct code. Just be sure this toggle is driven by your build’s actual environment variable and not left permanently on false during development, or you lose the safety net precisely when you need it most.
Where Immer stops paying for itself
Immer trades a small, well-understood runtime cost (Proxy interception, and optionally deep-freezing) for a large reduction in a specific, common class of bug: state mutated where a framework expected an immutable reference, or a copy made incorrectly by hand. That trade-off is worth it for the vast majority of nested application state, which is exactly why Redux Toolkit made it the default rather than an opt-in. It is worth it far less for flat, shallow state — a single counter, a single boolean flag, a form with three fields — where a spread operator already reads as clearly as a produce call would, and pulling in a Proxy buys you nothing. Knowing where that line sits, rather than reaching for Immer universally, is the difference between using the library well and using it as a reflex.
Frequently Asked Questions (FAQ)
Q. Why does produce(state, draft => draft.items.push(item)) throw an error?
A. A concise arrow function returns the value of its expression, and Array.prototype.push returns the new array length. Immer therefore sees a recipe that both mutated the draft and returned a non-undefined value, which is the mixed return-and-mutation case described in the recipe return values section above, and it throws instead of guessing which result you meant. Wrap the body in braces (draft => { draft.items.push(item) }) or prefix it with void so the recipe returns undefined and Immer uses the mutated draft.