Skip to content
React rc state-management 5 min read

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 produce is called with only a recipe (no base state), it returns a curried producer — a function (state) => newState. That is exactly the shape React’s functional setState updater expects, which is why setState(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

HelperSourceUse it for
produce(base, recipe)immerOne-off immutable updates outside React
produce(recipe)immerCurried producer for setState/reducers
useImmer(initial)use-immerDrop-in useState with draft updates
useImmerReducer(reducer, initial)use-immeruseReducer whose reducer mutates a draft
original(draft)immerRead the pre-draft value of a node
current(draft)immerSnapshot 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/useImmerReducer in components to avoid importing produce everywhere.
  • Never mix mutation and return in 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 of console.log(draft), since logging a Proxy is misleading.
  • Remember Redux Toolkit already bundles Immer — don’t add it again just to write createSlice reducers.
Last updated June 14, 2026
Was this helpful?