React Interview Prep
State and Props

Immutability in State

Immutability in State

LinkedIn Hook

You write state.items.push(newItem) and call setState(state).

The UI doesn't update. No error. No warning. Just... nothing.

You add a console.log — the array IS updated. But React doesn't care.

This is the mutation trap, and it catches developers at every level. The interviewer knows this, which is why "explain immutability in React state" is one of the most common React interview questions.

React uses reference comparison to decide whether to re-render. When you mutate an object, the reference stays the same — so React thinks nothing changed. Game over.

In this lesson, I break down WHY you can't mutate state directly, HOW to correctly update objects and arrays with the spread operator, how to handle deeply nested state, the most common mutation mistakes interviewers test, and when to reach for a library like Immer.

If you've ever been burned by a state update that "didn't work" — this lesson explains exactly why.

Read the full lesson -> [link]

#React #JavaScript #WebDevelopment #InterviewPrep #Frontend #CodingInterview #Immutability #ReactJS #100DaysOfCode


Immutability in State thumbnail


What You'll Learn

  • Why React requires immutable state updates (reference equality)
  • How to update objects and arrays without mutation using the spread operator
  • How to correctly update deeply nested state
  • The most common mutation mistakes interviewers test
  • When and why to use Immer for complex state updates

1. Why Immutability Matters in React

The Analogy

Imagine you're a librarian. Every time someone checks out a book, you don't erase the original catalog card and rewrite it — you print a new card with the updated status and replace the old one. Why? Because the library's tracking system compares the old card to the new card to decide what changed. If you just scribbled over the old card in place, the system would look at it, see the same card (same piece of paper), and conclude: "Nothing changed. No update needed."

That's exactly how React works. React compares the old state reference with the new state reference. If they point to the same object in memory, React skips the re-render — even if you changed every property inside that object.

How React Detects Changes

React uses reference equality (===) to determine whether state has changed. It does not deep-compare objects. It checks: "Is this the exact same object in memory, or a new one?"

const obj = { name: "Rakibul" };

// Mutation — same reference
obj.name = "Updated";
// obj === obj → true → React thinks nothing changed

// Immutable update — new reference
const newObj = { ...obj, name: "Updated" };
// newObj === obj → false → React detects the change and re-renders

This is not a React quirk. It's a deliberate design choice. Deep comparison of complex objects on every state change would be expensive. Reference checks are instantaneous — O(1) instead of O(n).


2. Spreading Objects for Updates

When you need to update a property on a state object, you create a new object with the spread operator and override only the property that changed.

Code Example 1: Updating Object State

import { useState } from "react";

function ProfileEditor() {
  const [user, setUser] = useState({
    name: "Rakibul",
    email: "rakibul@example.com",
    age: 25,
  });

  // WRONG — mutating state directly
  function handleBadUpdate() {
    user.name = "Updated";  // Mutates the existing object
    setUser(user);           // Same reference — React ignores it
    // Result: UI does NOT update
  }

  // RIGHT — creating a new object with spread
  function handleGoodUpdate() {
    setUser({
      ...user,              // Copy all existing properties
      name: "Updated",      // Override only the one that changed
    });
    // Result: New reference created — React re-renders
  }

  return (
    <div>
      <p>Name: {user.name}</p>
      <p>Email: {user.email}</p>
      <button onClick={handleGoodUpdate}>Update Name</button>
    </div>
  );
}

// After clicking "Update Name":
// Output: Name: Updated
//         Email: rakibul@example.com

The spread operator ...user copies every property from the old object into a new object. Then name: "Updated" overrides just that one key. The result is a new object with a new reference, so React detects the change.


3. Spreading Arrays for Updates

Arrays follow the same rule — never use mutating methods like push, pop, splice, or sort directly on state. Instead, use methods that return new arrays: map, filter, concat, spread, and slice.

Code Example 2: Updating Array State

import { useState } from "react";

function TodoList() {
  const [todos, setTodos] = useState([
    { id: 1, text: "Learn React", done: false },
    { id: 2, text: "Build a project", done: false },
  ]);

  // ADD — spread existing items and append the new one
  function addTodo(text) {
    setTodos([
      ...todos,
      { id: Date.now(), text, done: false },
    ]);
  }

  // REMOVE — filter out the item by id (returns a new array)
  function removeTodo(id) {
    setTodos(todos.filter(todo => todo.id !== id));
  }

  // UPDATE — map over and replace the matching item
  function toggleTodo(id) {
    setTodos(
      todos.map(todo =>
        todo.id === id
          ? { ...todo, done: !todo.done }  // New object for the changed item
          : todo                            // Keep unchanged items as-is
      )
    );
  }

  return (
    <div>
      {todos.map(todo => (
        <div key={todo.id}>
          <span style={{ textDecoration: todo.done ? "line-through" : "none" }}>
            {todo.text}
          </span>
          <button onClick={() => toggleTodo(todo.id)}>Toggle</button>
          <button onClick={() => removeTodo(todo.id)}>Delete</button>
        </div>
      ))}
      <button onClick={() => addTodo("New task")}>Add Todo</button>
    </div>
  );
}

// After toggling "Learn React":
// Output: Learn React (with line-through)
//         Build a project (no line-through)

Quick reference for array operations:

OperationMutating (avoid)Immutable (use this)
Addpush, unshift[...arr, item], [item, ...arr]
Removesplice, popfilter
Replacesplice, arr[i] = ...map
Sortsort[...arr].sort() (copy first)

Immutability in State visual 1


4. Updating Nested State

Nested state is where immutability gets painful. Every level of nesting requires its own spread to create a new reference at that level.

Code Example 3: Deeply Nested State Updates

import { useState } from "react";

function UserSettings() {
  const [user, setUser] = useState({
    name: "Rakibul",
    address: {
      city: "Dhaka",
      country: "Bangladesh",
      coordinates: {
        lat: 23.8103,
        lng: 90.4125,
      },
    },
  });

  // Update a deeply nested property — each level must be spread
  function updateCity(newCity) {
    setUser({
      ...user,                      // Copy top-level properties
      address: {
        ...user.address,            // Copy address properties
        city: newCity,              // Override city
      },
    });
  }

  // Update a three-level deep property
  function updateLatitude(newLat) {
    setUser({
      ...user,
      address: {
        ...user.address,
        coordinates: {
          ...user.address.coordinates,  // Copy coordinates properties
          lat: newLat,                   // Override latitude
        },
      },
    });
  }

  return (
    <div>
      <p>City: {user.address.city}</p>
      <p>Lat: {user.address.coordinates.lat}</p>
      <button onClick={() => updateCity("Chittagong")}>Change City</button>
      <button onClick={() => updateLatitude(22.3569)}>Change Lat</button>
    </div>
  );
}

// After clicking "Change City":
// Output: City: Chittagong
//         Lat: 23.8103

Notice the pattern: for every level between the root and the property you're changing, you must spread the object at that level. Miss one level and you either lose data or share a reference (which is a subtle mutation).

Why this matters in interviews: Interviewers love nested state questions because they test whether you truly understand immutability or just memorize ...spread at the top level.

Immutability in State visual 2


5. Enter Immer — Simplifying Immutable Updates

When nested state gets deeply complex, manually spreading at every level becomes error-prone and hard to read. This is where Immer comes in.

Immer lets you write code that looks like mutation, but produces immutable updates behind the scenes. It does this by giving you a "draft" proxy object. You mutate the draft freely, and Immer produces a new immutable object from your changes.

Code Example 4: Immer with useImmer

import { useImmer } from "use-immer";

function UserSettings() {
  const [user, updateUser] = useImmer({
    name: "Rakibul",
    address: {
      city: "Dhaka",
      country: "Bangladesh",
      coordinates: {
        lat: 23.8103,
        lng: 90.4125,
      },
    },
  });

  // With Immer — write "mutations" that are actually immutable
  function updateCity(newCity) {
    updateUser(draft => {
      draft.address.city = newCity;  // Looks like mutation, but it's safe
    });
  }

  // Deep nested update — no manual spreading needed
  function updateLatitude(newLat) {
    updateUser(draft => {
      draft.address.coordinates.lat = newLat;  // Clean and readable
    });
  }

  // Array operations work naturally too
  function addTag(tag) {
    updateUser(draft => {
      draft.tags.push(tag);  // push is fine inside an Immer draft
    });
  }

  return (
    <div>
      <p>City: {user.address.city}</p>
      <button onClick={() => updateCity("Chittagong")}>Change City</button>
    </div>
  );
}

// After clicking "Change City":
// Output: City: Chittagong
// Behind the scenes, Immer created a new object — no mutation occurred.

When to use Immer:

  • State is nested 3+ levels deep
  • You have complex array-of-objects updates
  • Your team finds spread-heavy code hard to maintain

When NOT to use Immer:

  • Simple flat state — spread is clearer and has no dependency
  • Performance-critical hot paths (Immer has a small overhead from proxy creation)

Immer is used by Redux Toolkit internally — if you've used createSlice, you've already used Immer without knowing it.


Common Mistakes

Mistake 1: Using Array Mutating Methods on State

const [items, setItems] = useState(["a", "b", "c"]);

// WRONG — push mutates the original array and returns the new length
function addItem() {
  items.push("d");       // Mutates the existing array
  setItems(items);       // Same reference — React skips re-render
}

// ALSO WRONG — sort mutates in place
function sortItems() {
  items.sort();           // Mutates the original array
  setItems(items);        // Same reference — no re-render
}

// RIGHT — create new arrays
function addItem() {
  setItems([...items, "d"]);          // Spread into new array
}

function sortItems() {
  setItems([...items].sort());        // Copy first, then sort the copy
}

Why it trips people up: push returns the array length (a number), not the array. And sort mutates in place while also returning the array — so even setItems(items.sort()) passes the same reference.

Mistake 2: Spreading Only the Top Level of Nested Objects

const [user, setUser] = useState({
  name: "Rakibul",
  address: { city: "Dhaka", zip: "1000" },
});

// WRONG — shallow spread shares the nested reference
function updateCity() {
  setUser({
    ...user,
    // address is still the SAME object reference from the old state
    // If you later mutate user.address somewhere, both old and new state are affected
  });
  // This doesn't even update city — it just copies the same address reference
}

// ALSO WRONG — mutating the nested object after spread
function updateCity() {
  const newUser = { ...user };
  newUser.address.city = "Chittagong";  // Mutates the ORIGINAL address object!
  setUser(newUser);
  // The spread only created a shallow copy — address is shared
}

// RIGHT — spread at every nested level
function updateCity() {
  setUser({
    ...user,
    address: {
      ...user.address,
      city: "Chittagong",
    },
  });
}

This is the most common mutation bug in React. Shallow spread only copies the top-level properties. Nested objects are still shared by reference.

Mistake 3: Forgetting That setState with the Same Reference Is a No-Op

const [form, setForm] = useState({ name: "", email: "" });

// WRONG — mutate then set the same object
function handleChange(field, value) {
  form[field] = value;   // Mutates the existing object
  setForm(form);          // Same reference — React bails out
}

// RIGHT — new object every time
function handleChange(field, value) {
  setForm({ ...form, [field]: value });
}

React uses Object.is() to compare old and new state. If they're the same reference, React skips the re-render entirely — no error, no warning, just silent failure.


Interview Questions

Q: Why can't you mutate state directly in React?

React uses reference equality (Object.is) to determine whether state has changed. When you mutate an object, the reference stays the same, so React concludes nothing changed and skips the re-render. Immutable updates create new object references, which React can detect. This design also enables performance optimizations like React.memo, time-travel debugging, and predictable component behavior.

Q: How do you update a specific property in a state object without mutating it?

Use the spread operator to create a new object, copying all existing properties and overriding the one that changed: setState({ ...state, property: newValue }). For nested properties, spread at every level between the root and the property you're changing.

Q: What is the difference between [...arr].sort() and arr.sort()?

arr.sort() mutates the original array in place and returns the same array reference. [...arr].sort() first creates a shallow copy via spread, then sorts the copy — leaving the original array unchanged. In React state, you must use [...arr].sort() because mutating the original would not trigger a re-render and would corrupt the current state.

Q: What are the immutable alternatives to push, splice, and sort for React state?

Instead of push, use [...arr, newItem]. Instead of splice for removal, use arr.filter(). Instead of splice for replacement, use arr.map(). Instead of sort, use [...arr].sort(). The key principle: always produce a new array rather than modifying the existing one.

Q: What is Immer, and when would you use it?

Immer is a library that lets you write state updates using mutable syntax while producing immutable results. It uses JavaScript proxies to create a "draft" of your state — you modify the draft, and Immer generates a new immutable state from your changes. It's useful for deeply nested state (3+ levels), complex array-of-objects operations, and when spread-heavy code becomes hard to read. Redux Toolkit uses Immer internally in its createSlice function.


Quick Reference — Cheat Sheet

+----------------------------------------------------------------------+
|             IMMUTABILITY IN STATE — CHEAT SHEET                       |
+----------------------------------------------------------------------+
|                                                                      |
|  WHY IMMUTABILITY?                                                   |
|  React compares state with Object.is (reference equality).           |
|  Same reference = no re-render. New reference = re-render.           |
|                                                                      |
|  OBJECT UPDATES                                                      |
|  setState({ ...state, key: newValue })                               |
|  Nested: setState({ ...state, nested: { ...state.nested, k: v } })  |
|                                                                      |
|  ARRAY UPDATES                                                       |
|  ┌───────────┬─────────────────────┬───────────────────────────┐     |
|  │ Operation │ Mutating (avoid)    │ Immutable (use this)      │     |
|  ├───────────┼─────────────────────┼───────────────────────────┤     |
|  │ Add       │ push, unshift       │ [...arr, item]            │     |
|  │ Remove    │ splice, pop, shift  │ arr.filter(...)           │     |
|  │ Replace   │ arr[i] = x          │ arr.map(...)              │     |
|  │ Sort      │ arr.sort()          │ [...arr].sort()           │     |
|  │ Reverse   │ arr.reverse()       │ [...arr].reverse()        │     |
|  └───────────┴─────────────────────┴───────────────────────────┘     |
|                                                                      |
|  NESTED STATE RULE                                                   |
|  Spread at EVERY level between root and the changed property.        |
|  Miss one level = shared reference = silent mutation bug.            |
|                                                                      |
|  IMMER                                                               |
|  useImmer(initialState) → [state, updateFn]                          |
|  updateFn(draft => { draft.deep.prop = val; })                       |
|  Looks like mutation, produces immutable result.                     |
|  Used internally by Redux Toolkit (createSlice).                     |
|                                                                      |
|  GOLDEN RULES                                                        |
|  1. Never mutate state — always create new references               |
|  2. Spread copies are shallow — spread nested levels too            |
|  3. Use functional updates when new state depends on old state      |
|  4. Consider Immer when nesting gets 3+ levels deep                 |
|                                                                      |
+----------------------------------------------------------------------+

Previous: Lesson 2.4 — Controlled vs Uncontrolled Components -> Next: Lesson 3.1 — useState Deep Dive ->


This is Lesson 2.5 of the React Interview Prep Course — 10 chapters, 42 lessons.

On this page