Immutable Updates with Immer
React state must be updated immutably: you never mutate the existing object, you create a new one. For flat state this is easy, but the moment your state nests a few levels deep, the spread syntax becomes a wall of ... that is hard to read and easy to get wrong. Immer fixes this by letting you write code that looks like a mutation while producing a brand-new immutable value behind the scenes. It is small, fast, and so reliable that Redux Toolkit builds it into every reducer.
The nested-spread problem
Imagine a settings object that groups preferences by category. To toggle one deeply nested flag immutably, you must copy every level on the path to the change while leaving the rest untouched.
import { useState } from "react";
function Settings() {
const [state, setState] = useState({
user: { name: "Ada", prefs: { theme: "light", notifications: { email: true, sms: false } } },
});
const toggleSms = () => {
setState((s) => ({
...s,
user: {
...s.user,
prefs: {
...s.user.prefs,
notifications: {
...s.user.prefs.notifications,
sms: !s.user.prefs.notifications.sms,
},
},
},
}));
};
return <button onClick={toggleSms}>Toggle SMS</button>;
}
Every level is spread purely to preserve immutability. Forget one spread and you mutate shared state, causing stale renders and subtle bugs.
The produce() draft model
Immer’s core is a single function, produce(baseState, recipe). It hands your recipe a draft — a mutable proxy of the current state. You mutate the draft freely, and Immer records every change and returns a new immutable state. The original is never touched; untouched branches are structurally shared, so the result is cheap to create and cheap to compare.
import { produce } from "immer";
const next = produce(state, (draft) => {
draft.user.prefs.notifications.sms = !draft.user.prefs.notifications.sms;
});
Wired into a React updater, the entire spread pyramid collapses to one line:
import { useState } from "react";
import { produce } from "immer";
function Settings() {
const [state, setState] = useState({
user: { name: "Ada", prefs: { theme: "light", notifications: { email: true, sms: false } } },
});
const toggleSms = () =>
setState(
produce((draft) => {
draft.user.prefs.notifications.sms = !draft.user.prefs.notifications.sms;
})
);
return <button onClick={toggleSms}>Toggle SMS</button>;
}
When
produceis called with only a recipe (no base state), it returns a curried producer — a function(state) => newState. That is exactly the shape React’s functionalsetStateupdater expects, which is whysetState(produce(...))works directly.
Arrays become trivial
Adding, removing, and editing items in arrays is where Immer shines most, because mutating array methods are the natural way to express the change.
import { produce } from "immer";
const addTodo = (todos, text) =>
produce(todos, (draft) => {
draft.push({ id: crypto.randomUUID(), text, done: false });
});
const toggleTodo = (todos, id) =>
produce(todos, (draft) => {
const todo = draft.find((t) => t.id === id);
if (todo) todo.done = !todo.done;
});
const removeTodo = (todos, id) =>
produce(todos, (draft) => {
const i = draft.findIndex((t) => t.id === id);
if (i !== -1) draft.splice(i, 1);
});
Each function returns a new array; the originals stay frozen and untouched.
useImmer and useImmerReducer
The use-immer package wraps useState and useReducer so you never call produce by hand. useImmer returns an updater that already runs your callback through a producer.
import { useImmer } from "use-immer";
function TodoApp() {
const [todos, updateTodos] = useImmer([{ id: "1", text: "Learn Immer", done: false }]);
const add = (text) =>
updateTodos((draft) => {
draft.push({ id: crypto.randomUUID(), text, done: false });
});
const toggle = (id) =>
updateTodos((draft) => {
const t = draft.find((x) => x.id === id);
if (t) t.done = !t.done;
});
return (
<ul>
{todos.map((t) => (
<li key={t.id} onClick={() => toggle(t.id)} style={{ textDecoration: t.done ? "line-through" : "none" }}>
{t.text}
</li>
))}
<button onClick={() => add("New task")}>Add</button>
</ul>
);
}
For reducer-driven state, useImmerReducer lets each case mutate the draft instead of returning a fresh object.
import { useImmerReducer } from "use-immer";
function reducer(draft, action) {
switch (action.type) {
case "increment":
draft.count += 1;
break;
case "reset":
draft.count = 0;
break;
}
}
function Counter() {
const [state, dispatch] = useImmerReducer(reducer, { count: 0 });
return <button onClick={() => dispatch({ type: "increment" })}>{state.count}</button>;
}
Redux Toolkit uses Immer under the hood
If you write Redux with createSlice, you are already using Immer. RTK pipes every reducer through produce, so the state parameter is a draft. That is why this “mutating” reducer is correct and safe.
import { createSlice } from "@reduxjs/toolkit";
const cartSlice = createSlice({
name: "cart",
initialState: { items: [] },
reducers: {
addItem(state, action) {
state.items.push(action.payload); // looks mutable, produces new state
},
clear(state) {
state.items = [];
},
},
});
Inside a draft you may either mutate or return a new value, but not both in the same recipe. Returning a value while also mutating the draft throws an error.
API reference
| Helper | Source | Use it for |
|---|---|---|
produce(base, recipe) | immer | One-off immutable updates outside React |
produce(recipe) | immer | Curried producer for setState/reducers |
useImmer(initial) | use-immer | Drop-in useState with draft updates |
useImmerReducer(reducer, initial) | use-immer | useReducer whose reducer mutates a draft |
original(draft) | immer | Read the pre-draft value of a node |
current(draft) | immer | Snapshot the draft’s current state for logging |
Best Practices
- Reach for Immer when state nests two or more levels deep; for flat state, plain spreads are simpler and dependency-free.
- Prefer the curried
produce(recipe)form so it slots straight into React’s functional updater. - Use
useImmer/useImmerReducerin components to avoid importingproduceeverywhere. - Never mix mutation and
returnin the same recipe — pick one approach per producer. - Keep recipes synchronous; do not perform side effects or async work against a draft.
- Use
current(draft)for debugging instead ofconsole.log(draft), since logging a Proxy is misleading. - Remember Redux Toolkit already bundles Immer — don’t add it again just to write
createSlicereducers.