Skip to solution
hardFrontend

Why must React state updates be immutable?

601 views
01

Understand 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.

reactstateimmutability
02

Attempt it yourself

Sketch your approach before reading the solution — that's what interviews test.

Nudge consolestandby

Stuck? Beam a request up — the console returns a conceptual nudge that guides your logic without spoiling the implementation.

03

Study the solution

Target Audience: Engineers preparing for senior React interviews — assumes state basics. Difficulty: Hard

How to read this doc: Concepts are explained in plain language first, then tagged with 📌 Interview term:. The render counts and the delayed corruption in section 3 were produced by actu

Solution ready — 2 min read

Classified // press E to declassify

04

Explore the playground snippets

The mutation that does nothing, then reveals itself later
Run Playground
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>
  );
}
05

Join the discussion

Discussion (0)

Sign in to join the discussion.

No responses yet. Be the first to share what you think.

Transmission complete // awaiting log

KEEP THE
STREAK ALIVE.

Dossier 95 of 119 decoded in the React.js track. One more won't hurt.

Back to track