Skip to the solution
HardFrontend6 min read

Why must React state updates be immutable?

601 views

The problem

Question presented to candidate: "Why can you not just push to an array in state and call the setter with the same array?"

What a strong answer should cover:

  • React decides whether state changed by comparing the reference with Object.is β€” a shallow, identity check, never a deep one.
  • Mutating in place leaves the reference identical, so React sees no change and bails out of re-rendering entirely.
  • The dangerous part is not the missed render, it is that the mutation still happened. The data is now wrong and the screen is stale, and the corruption surfaces later during an unrelated update.
  • Why reference comparison: deep-comparing every state value on every update would be prohibitively expensive, and impossible for functions.
  • Immutability is also what makes React.memo, useMemo dependency arrays, and context value comparison work at all β€” they are all reference checks.
  • It underpins concurrent rendering: React can hold a previous state value and render both, which is impossible if the object was mutated in place.
  • The patterns: spread for objects and arrays, map/filter/toSorted rather than splice/sort/reverse, and a library like Immer when nesting gets deep.
  • StrictMode freezes state in development for some cases; the discipline is required regardless.

Clarifying questions expected:

  • "Is the state deeply nested? That changes whether I would hand-spread or reach for Immer."

Code / implementation expected: Yes β€” the mutation that silently does nothing, beside the correct copy.

  • react
  • state
  • immutability

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

6 min read Β· 8 sections

Try it in the playground

Open any example in the playground to run and change it.

ReactThe mutation that does nothing, then reveals itself later
import { useState, useRef } from "react";

export default function App() {
  const [items, setItems] = useState(["apple", "banana"]);
  const [log, setLog] = useState([]);
  const renders = useRef(0);
  renders.current++;

  const push = (line) => setLog((l) => [...l.slice(-4), line]);

  // ❌ Mutates in place, then hands React the SAME reference. Object.is says
  //    nothing changed, so React bails out β€” but the array really did change.
  const mutate = () => {
    items.push("mutated-" + Date.now().toString().slice(-4));
    setItems(items);
    push("mutated + setItems(same ref) β€” expect no visible change");
  };

  // βœ… Builds a new array. New reference, so React re-renders.
  const copy = () => {
    setItems((prev) => [...prev, "copied"]);
    push("setItems(new array) β€” re-renders");
  };

  // ❌ sort() mutates AND returns the array, which is why it fools people.
  const sortBadly = () => {
    setItems(items.sort());
    push("items.sort() β€” mutates in place, same ref");
  };

  // βœ… toSorted() returns a new array, leaving the original alone.
  const sortWell = () => {
    setItems((prev) => prev.toSorted());
    push("toSorted() β€” new array");
  };

  return (
    <div style={{ padding: 24, fontFamily: "system-ui", lineHeight: 1.6, maxWidth: 520 }}>
      <p style={{ fontSize: 14 }}>
        renders: <strong>{renders.current}</strong> Β· items in state:{" "}
        <strong>{items.length}</strong>
      </p>

      <ul style={{ background: "#f6f6f6", padding: "8px 8px 8px 28px", borderRadius: 6 }}>
        {items.map((it, i) => <li key={i}>{it}</li>)}
      </ul>

      <p>
        <button onClick={mutate} style={{ marginRight: 6 }}>❌ mutate</button>
        <button onClick={copy} style={{ marginRight: 6 }}>βœ… copy</button>
        <button onClick={sortBadly} style={{ marginRight: 6 }}>❌ sort()</button>
        <button onClick={sortWell}>βœ… toSorted()</button>
      </p>

      <pre style={{ background: "#f6f6f6", padding: 10, borderRadius: 6, fontSize: 12 }}>
        {log.length ? log.join("\n") : "(press a button)"}
      </pre>

      <p style={{ color: "#666", fontSize: 13 }}>
        Press ❌ mutate two or three times β€” nothing appears to happen. Now press
        βœ… copy once: every hidden mutation shows up at the same moment. That
        delay between cause and symptom is what makes mutation so hard to debug.
      </p>
    </div>
  );
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 95 of 119 in React.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All React.js questions
Up nextHard

Compare render-as-you-fetch, fetch-on-render, and fetch-then-render.

Continue