Immutability in React State

Understand why React requires immutable updates to state, and master the patterns for updating objects, arrays, and deeply nested state without mutating the original data.

React decides whether to re-render a component by comparing the previous state value to the new one, and for objects and arrays it does this comparison by reference (Object.is), not by deep value inspection. If you mutate an object in place and pass the same reference back to setState, React sees the identical reference and assumes nothing changed — your UI silently fails to update. This single fact is the root of one of the most common classes of React bugs, and it is why 'treat state as immutable' is one of the first hard rules every React developer must internalize. This lesson builds a systematic toolkit for updating state immutably: spreading objects and arrays, updating nested structures level by level, functional updates that avoid stale closures, the idea of structural sharing that makes immutable updates cheap, and Immer, a library that lets you write mutation-looking code that produces immutable results under the hood.

Why React needs immutable updates

When you call a state setter like setUser(nextUser), React does not walk through every field of nextUser and compare it to every field of the old user object. That would be slow for large state trees and impossible to do generically. Instead, React (and the useState/useReducer hooks specifically) performs a reference equality check: is nextUser === prevUser? If the reference is unchanged, React assumes the state is unchanged and — for useState — bails out of re-rendering that component (this bailout is an optimization documented behavior, not just an implementation detail).

This means that code like user.name = 'Ada'; setUser(user); is broken: user still points to the same object in memory, so the reference comparison sees no difference, and React may skip the re-render entirely, or worse, produce ordering bugs where some parts of the UI update (because other state changed and forced a render) while this particular value is stale in memory tracking, DevTools time-travel, and dependency arrays like useEffect's and useMemo's, which also rely on reference equality.

Immutability also enables cheap change detection for anything downstream that memoizes: React.memo, useMemo, and useCallback all use reference equality on their dependencies or props. If you mutate state instead of replacing it, memoized children that compare props by reference will incorrectly conclude 'nothing changed' and skip re-rendering even when the data did change — the opposite failure mode, and just as damaging as an unnecessary re-render.

Immutable updates to objects and arrays

The basic tool for immutable updates is the spread syntax (...), which creates a new object or array whose own enumerable properties/elements are shallow copies of the source. For an object, { ...obj, field: newValue } produces a brand-new object reference that has all of obj's properties, with field overwritten. For an array, [...arr, newItem] (append), [newItem, ...arr] (prepend), or arr.filter(...) / arr.map(...) (removal / transformation) each produce a new array reference without touching the original.

It is critical to understand that spreading is shallow. { ...state, address: state.address } copies the *reference* to address, not a deep clone of it. If address is itself an object and you need to change a field inside it, spreading the outer object alone is not enough — you must also spread (or otherwise replace) the nested object, and this requirement cascades to every level of nesting you intend to change. This is exactly why deeply nested state is discouraged in React: each level of nesting adds another spread you must remember to write correctly.

Arrays have array-specific mutating methods you must avoid on state: push, pop, shift, unshift, splice, sort, and reverse all mutate the array in place and return either the mutated array or an unrelated value (e.g., push returns the new length, not the array). Prefer their non-mutating equivalents: [...arr, item] instead of push, arr.filter(x => x !== item) instead of splice-based removal, arr.map(...) for per-item transformation, and [...arr].sort(...) (spread first, to sort a copy) instead of arr.sort(...) directly on state.

• Objects: { ...obj, key: value } — shallow copy plus overwrite. • Add to array: [...arr, item] or [item, ...arr]. • Remove from array: arr.filter(item => item.id !== targetId). • Update one item in an array: arr.map(item => item.id === targetId ? { ...item, done: true } : item). • Never call push, pop, splice, sort, or reverse directly on a state array — they mutate in place.

Nested state and functional updates

For state shaped like { user: { profile: { address: { city: 'NYC' } } } }, updating city immutably means spreading every ancestor level: setState(prev => ({ ...prev, user: { ...prev.user, profile: { ...prev.user.profile, address: { ...prev.user.profile.address, city: 'Boston' } } } })). This is verbose and error-prone by hand — it is the practical argument, beyond raw performance, for keeping React state flat where possible, normalizing nested collections by id (similar to a database's normal form), or reaching for a helper library once nesting gets deep.

The functional update form of setState — passing a function prevState => newState instead of a plain value — solves a different but related problem: stale closures. If you compute the next value from a variable captured in a closure (e.g., a count from the render that scheduled this update), and multiple updates are batched or scheduled in quick succession, each one may see the same stale count and overwrite the other. setCount(count + 1) called twice in the same event handler only increments once, because both calls close over the same count. setCount(prev => prev + 1) called twice increments twice, because each functional update receives the *actual* latest state at the time React applies it, not the value captured in the render's closure.

Functional updates are also the safe way to update nested or array state without depending on a variable from the enclosing scope: setTodos(prev => prev.map(t => t.id === id ? { ...t, done: !t.done } : t)) always operates on the freshest array, which matters especially inside useEffect callbacks, timers, or async handlers that might run after other updates have already been scheduled.

Structural sharing

A natural worry about 'always create new objects' is that it sounds expensive — surely copying deeply nested state for every keystroke is wasteful? In practice it is not, because immutable updates use structural sharing: only the objects on the path from the root to the changed value are recreated; every sibling subtree that did not change keeps its original reference and is reused, not cloned. In { ...prev, user: { ...prev.user, name: 'Ada' } }, prev.settings, prev.todos, and every other top-level field are the *same* object references as before — only prev itself and prev.user are new objects. The cost of an immutable update is proportional to the depth of the change, not the size of the whole state tree.

Structural sharing is also what makes reference-equality checks meaningful in the first place: because unchanged subtrees keep their identity, a child component that receives state.settings as a prop and is wrapped in React.memo will correctly skip re-rendering when only state.user.name changed, since state.settings === prevState.settings is still true. Mutating state in place destroys this property — everything looks 'possibly changed' because there's no way to tell old from new by reference anymore.

Immer: mutable syntax, immutable results

Immer is a widely used library (and the mechanism behind useImmer/useImmerReducer and Redux Toolkit's createSlice reducers) that lets you write updates using ordinary mutation syntax against a special 'draft' object, and produces a correctly immutable new state object automatically. Its core function is produce(baseState, recipe): recipe receives a draft proxy that looks and behaves like baseState, you mutate the draft however is natural (draft.user.profile.address.city = 'Boston', draft.todos.push(newTodo)), and produce returns a new, structurally-shared state object reflecting those changes, leaving baseState completely untouched.

Under the hood, Immer wraps the draft in ES6 Proxies that record which paths were touched; when the recipe function returns (or falls off the end, for a void recipe), Immer walks only the touched paths and produces new objects for exactly those, reusing every untouched subtree by reference — the same structural-sharing guarantee you get from hand-written spreads, but without having to write the nested spreads yourself. This makes Immer especially valuable for deeply nested state and for reducers with many branches, where hand-written spread chains become hard to read and easy to get subtly wrong (e.g., forgetting to spread one intermediate level, which silently mutates shared state).

Immer is a tool for ergonomics, not a replacement for understanding immutability. You should still reach for flatter state shapes when practical, and you should still understand what produce is doing underneath, because debugging an Immer-based reducer, reasoning about why a memoized child didn't re-render, or working in a codebase that doesn't use Immer all require the same mental model this lesson builds by hand.

Core immutable update patterns

Always pass a *new* reference to the setter — never reuse the previous object or array by reference. The functional-update form setX(prev => ...) is preferred whenever the next state depends on the previous state. Spreading is shallow: spread every level of nesting you intend to modify, not just the outermost object.

// --- Objects: shallow update ---
setUser(prev => ({ ...prev, name: "Ada" }));

// --- Objects: nested update (spread every ancestor level) ---
setUser(prev => ({
  ...prev,
  profile: {
    ...prev.profile,
    address: { ...prev.profile.address, city: "Boston" },
  },
}));

// --- Arrays: add ---
setItems(prev => [...prev, newItem]);

// --- Arrays: remove ---
setItems(prev => prev.filter(item => item.id !== targetId));

// --- Arrays: update one item ---
setItems(prev =>
  prev.map(item => (item.id === targetId ? { ...item, done: true } : item))
);

// --- Functional update to avoid stale closures ---
setCount(prev => prev + 1);

// --- Immer equivalent of the nested object update above ---
import { produce } from "immer";
setUser(produce(draft => {
  draft.profile.address.city = "Boston";
}));

Code Examples

prev.map creates a new array; for the matched todo, a new todo object is created with a new meta object and a new tags array, while every other todo in the list keeps its original reference (structural sharing). This is the minimal correct pattern for updating one field, two levels deep, inside one item of an array.

meta: {...todo.meta, tags: [...todo.meta.tags, tag] } Common mistake: A common bug here is todo.meta.tags.push(tag); return todo; — this mutates the shared tags array in place (even the unmatched todos' map branch returns todo unchanged, but the *matched* todo's array was still mutated before the check), and because the returned todo reference for the matched item is unchanged, React may not even re-render it.

Inside the Immer recipe, todo.meta.tags.push(tag) looks like ordinary in-place mutation, but it is operating on a Proxy-wrapped draft. Immer tracks that draft[i].meta.tags was touched and produces a new array, a new meta object, and a new todo object at that index — everything else in draft is returned by reference, unchanged. The result assigned back via setTodos is a fully new, immutable array, functionally identical to the hand-written version above. Expected result: todos updates with the new tag; every other todo object keeps its original reference

count in incrementTwice is a value captured from the render that created this event handler; both setCount(count + 1) calls compute 0 + 1 = 1 and schedule the same replacement value, so the net effect is +1, not +2. incrementTwiceCorrectly passes updater functions instead; React applies them in order against whatever the state actually is at that point in the update queue, so the second updater sees the result of the first.

Common mistake: Calling setCount(count + 1) more than once in the same handler when you intend cumulative increments.

interface Todo {
  id: number;
  text: string;
  done: boolean;
  meta: { priority: "low" | "high"; tags: string[] };
}

function TodoList() {
  const [todos, setTodos] = useState<Todo[]>([
    { id: 1, text: "Write lesson", done: false, meta: { priority: "high", tags: ["react"] } },
  ]);

  function addTag(id: number, tag: string) {
    setTodos(prev =>
      prev.map(todo =>
        todo.id !== id
          ? todo
          : {
              ...todo,
              meta: { ...todo.meta, tags: [...todo.meta.tags, tag] },
            }
      )
    );
  }

  return (
    <ul>
      {todos.map(t => (
        <li key={t.id}>
          {t.text} — tags: {t.meta.tags.join(", ")}
          <button onClick={() => addTag(t.id, "urgent")}>Tag urgent</button>
        </li>
      ))}
    </ul>
  );
}
import { produce } from "immer";

function addTagImmer(id: number, tag: string) {
  setTodos(produce(draft => {
    const todo = draft.find(t => t.id === id);
    if (todo) todo.meta.tags.push(tag);
  }));
}
function Counter() {
  const [count, setCount] = useState(0);

  function incrementTwice() {
    // BROKEN: both calls close over the same "count" from this render
    setCount(count + 1);
    setCount(count + 1);
    // count only ever increases by 1, not 2
  }

  function incrementTwiceCorrectly() {
    // CORRECT: each call receives the true latest state
    setCount(prev => prev + 1);
    setCount(prev => prev + 1);
  }

  return (
    <div>
      <p>{count}</p>
      <button onClick={incrementTwiceCorrectly}>+2</button>
    </div>
  );
}

Important Rules

Never assign to a property of a state object or state array directly (state.x = y, arr[i] = y) — always produce a new object/array via spread, map/filter, or Immer's produce.

Spreading is shallow: to update a nested field, spread every ancestor object on the path to that field, or use Immer to do it for you.

Avoid array-mutating methods (push, pop, shift, unshift, splice, sort, reverse) directly on state; use their non-mutating equivalents or spread a copy first.

Prefer the functional updater form setState(prev => ...) whenever the next value depends on the current value, to avoid stale-closure bugs.

Immutable updates are cheap because of structural sharing: only objects on the path to the change are recreated, not the entire tree — keep this in mind rather than avoiding immutability for 'performance' reasons.

Reach for Immer (or a normalized/flatter state shape) once nested spreads become more than two or three levels deep and hard to read correctly.

Redux Toolkit and useImmerReducer

Redux Toolkit's createSlice uses Immer internally so that reducers can be written with direct-looking mutation (state.user.name = action.payload) while still producing correctly immutable state under the hood — this is one of the main reasons Redux Toolkit replaced hand-written spread-heavy reducers as the default way to write Redux logic. The same idea shows up in the community useImmerReducer hook for local component state. Understanding plain immutable updates first is what makes it possible to read Immer-based code critically: you know exactly what invariant produce is preserving, so you can spot when a recipe accidentally does something outside the draft (like mutating a variable captured from outer scope) that Immer cannot track.

state.items.push(newItem); setItems(state.items);

setItems(prev => [...prev, newItem]);

push mutates the array in place; setItems receives the same reference back, so React may skip the re-render, and any memoized consumers of the old array are now silently out of sync with reality.

setUser({ ...user, address: user.address.city = "Boston" });

setUser(prev => ({ ...prev, address: { ...prev.address, city: "Boston" } }));

The assignment expression user.address.city = 'Boston' mutates the shared address object as a side effect (and evaluates to the string 'Boston', overwriting the whole address field with a string) — both the mutation and the type error need to be fixed by properly spreading the nested address object.

setCount(count + 1); setCount(count + 1);

setCount(prev => prev + 1); setCount(prev => prev + 1);

Both calls close over the same stale count value from the render, so the state only advances by one step instead of two; functional updates always operate on the true latest state.

Why does mutating a state object in place and calling setState with the same reference often fail to trigger a re-render? A. React deep-compares old and new state, and mutation makes the values look identical B. React compares state by reference (Object.is), and the mutated object still has the same reference as before C. setState only works with primitive values, not objects D. React always re-renders regardless, so this scenario cannot occur

B. React compares state by reference (Object.is), and the mutated object still has the same reference as before — React's change detection for useState/useReducer is a reference equality check. Mutating an object leaves its reference unchanged, so React concludes nothing changed and may skip the re-render.

What does { ...state, address: { ...state.address, city: 'Boston' } } correctly update? A. Only the city field, leaving state and state.address mutated in place B. A new state object and a new address object, with city overwritten, while unrelated fields keep their original references C. Nothing — spreading an object does not create a new reference D. It replaces the entire address object with just { city: 'Boston' }

B. A new state object and a new address object, with city overwritten, while unrelated fields keep their original references — This is a correctly nested immutable update: a new outer object, a new inner address object with city overwritten, and — thanks to structural sharing — every other field on state keeps its original reference.

Why can calling setCount(count + 1) twice in the same event handler fail to increment count by 2? A. React batches renders, so the second call is simply ignored B. Both calls close over the same stale 'count' value from the render that created the handler, so both compute the same next value C. setState can only be called once per event handler D. count is a reference type, so + 1 mutates it unpredictably

B. Both calls close over the same stale 'count' value from the render that created the handler, so both compute the same next value — count is captured in the closure at render time; both setCount(count + 1) calls use that same stale value. Functional updates (setCount(prev => prev + 1)) avoid this by always operating on the latest state.

What does Immer's produce(base, recipe) function return? A. The mutated base object itself, now changed in place B. A new object reflecting the recipe's mutations to the draft, with unchanged subtrees reused by reference (structural sharing), leaving base untouched C. A deep clone of base with every nested object recreated, regardless of what changed D. A Promise that resolves to the new state

B. A new object reflecting the recipe's mutations to the draft, with unchanged subtrees reused by reference (structural sharing), leaving base untouched — produce leaves the original base object untouched and returns a new object where only the paths actually mutated on the draft are replaced; every untouched subtree is reused by reference, same as a correct hand-written immutable update.

Given state const [cart, setCart] = useState({ items: [{ id: 1, qty: 2 }, { id: 2, qty: 1 }], discountCode: null }), write an immutable update function incrementQty(id: number) that increases the qty of the item with the given id by 1, without mutating cart, cart.items, or any item object. Then rewrite it using Immer's produce.

function incrementQty(id: number) { setCart(prev => ({ ...prev, items: prev.items.map(item => item.id === id ? { ...item, qty: item.qty + 1 } : item), })); } // Immer version: function incrementQtyImmer(id: number) { setCart(produce(draft => { const item = draft.items.find(i => i.id === id); if (item) item.qty += 1; })); }

You need a new cart object, a new items array (via map), and a new object for the one item whose qty changed — every other item object should keep its original reference.