Skip to main content

Immutability in React state

Immutability in React state means creating new objects or arrays instead of modifying existing ones, because React compares references to detect changes, not the actual values inside.

Theory

TL;DR

  • React uses Object.is() (reference equality) to check if state changed. Not deep comparison.
  • Mutating state directly keeps the same reference, so React sees no change and skips the re-render.
  • Create new objects with spread: { ...obj, key: newValue }. Create new arrays with map, filter, or spread.
  • Decision rule: if you're writing state.something = value, you're mutating. That won't work.

Quick example

tsx
const [user, setUser] = useState({ name: "Alice", age: 25 }); // ❌ Wrong - mutates in place, same reference, no re-render user.age = 26; setUser(user); // ✅ Right - new object, new reference, React re-renders setUser({ ...user, age: 26 }); // Component re-renders, displays age: 26

The spread operator creates a new object in memory. React compares the old and new references, sees they differ, and queues a re-render.

How React detects state changes

React stores a reference to your state in its internal fiber tree. Every setState call triggers a comparison: Object.is(prevState, nextState). If that returns true, React skips the component's render function entirely. If false, it schedules a re-render and reconciles the DOM.

So setUser(user) after mutating user.age does nothing. The variable user still points at the same object in memory. React checks, gets true, moves on.

This mechanism also explains why React.memo, useMemo, and useCallback work the way they do. All three are built on the same reference comparison.

When to use each approach

  • Single field update on an object: setUser({ ...user, age: 26 })
  • Nested object update: spread each level, setState({ ...state, user: { ...state.user, name: "Bob" } })
  • Add to array: setItems([...items, newItem])
  • Remove from array: setItems(items.filter(item => item.id !== id))
  • Update one item in array: setItems(items.map(item => item.id === id ? { ...item, ...changes } : item))
  • Deeply nested state with many levels: use Immer

Immutable operations reference

OperationMutatingImmutable
Add to arraypush(), unshift()[...arr, item]
Remove from arraysplice(), pop()filter()
Replace in arrayarr[i] = xmap()
Sort arraysort()[...arr].sort()
Update object fieldobj.key = val{ ...obj, key: val }
Delete propertydelete obj.keyconst { key, ...rest } = obj

Common mistakes

1. Mutating a nested object

tsx
// ❌ Wrong - outer reference unchanged, React skips re-render state.user.name = "Bob"; setState(state); // ✅ Right - new outer object AND new nested object setState({ ...state, user: { ...state.user, name: "Bob" } });

React checks the outer object reference. It hasn't changed. Re-render skipped.

2. Using mutating array methods

tsx
// ❌ Wrong - push() modifies the original, same reference items.push(newItem); setItems(items); // ✅ Right - new array reference setItems([...items, newItem]);

push(), pop(), splice(), sort(), and reverse() all modify the original array. Replace them with non-mutating alternatives.

3. Thinking spread covers nested objects

tsx
// ❌ Partially wrong - drops any other fields address had setUser({ ...user, address: { city: "NYC" } }); // ✅ Right - spread the nested level too setUser({ ...user, address: { ...user.address, city: "NYC" } });

Spread is shallow. It copies top-level properties, but nested objects are still shared references.

4. Mutating an item inside an array, then spreading the array

tsx
// ❌ Wrong - new array, but todos[0] is still the same object reference const next = [...todos]; next[0].done = true; setTodos(next); // ✅ Right - new array, new item object setTodos(todos.map(todo => todo.id === id ? { ...todo, done: true } : todo ));

In practice, this is the sneakiest mutation bug. Developers who already know "don't mutate state directly" spread the array, feel confident, and then spend an hour debugging why the list item didn't update. The array is new. The objects inside are not.

5. Reading state right after setState

tsx
// ❌ Wrong assumption - count is still the old value here setCount(count + 1); console.log(count); // ✅ Use the updater function when next state depends on previous setCount(prev => prev + 1);

setState schedules an update for the next render. It doesn't change the variable synchronously.

Real-world usage

  • useState - every update needs a new reference to trigger re-render
  • Redux reducers - must return new state, never mutate the argument passed in
  • Zustand - store update functions should return new objects for predictable state
  • React Query - cache updates follow the same pattern for consistency
  • Immer - lets you write mutation-style code that produces immutable results under the hood
  • React.memo and useMemo - stable references prevent unnecessary re-renders of child components

Follow-up questions

Q: Why does React use reference comparison instead of deep equality?
A: Deep equality checks every property recursively, which is O(n) per state update. Reference comparison is O(1). It also makes intent explicit: creating a new object signals "this is a changed value."

Q: What if I accidentally mutate state but the component still re-renders because a parent prop changed?
A: The component re-renders, but with corrupted state. Your mutation changed the old state object. React's internal state was never updated. The render uses whatever is in the fiber, ignoring your mutation. You get a visual mismatch and a hard-to-trace bug.

Q: How does Immer solve deeply nested updates without endless spread operators?
A: Immer wraps your state in a Proxy called a "draft." You write code that looks like direct mutation (draft.user.address.city = "NYC"), and Immer tracks every change. When you're done, it produces a new immutable object. It only copies branches that were actually modified, so it stays efficient even for large state trees.

Q: I have 10,000 items in an array. Doesn't creating a new array on every update waste memory?
A: Spreading large arrays is fast in modern JS engines, so it rarely matters. But if you update individual items frequently, normalize your state: store items as an object keyed by ID instead of an array. Then updating one item is a single property assignment, not a full array copy. Most apps never need to think about this.

Examples

Updating form state with nested preferences

tsx
const [formData, setFormData] = useState({ email: "", preferences: { notifications: true, theme: "dark" } }); function handleEmailChange(e) { // Only email changes; preferences reference stays the same - that's fine setFormData({ ...formData, email: e.target.value }); } function toggleNotifications() { // New outer object AND new preferences object setFormData({ ...formData, preferences: { ...formData.preferences, notifications: !formData.preferences.notifications } }); }

handleEmailChange creates a new top-level object. The preferences property still points at the same object, which is fine since it didn't change. toggleNotifications needs a new preferences object because that's the level that actually changed.

Updating one item in an array of objects

tsx
const [todos, setTodos] = useState([ { id: 1, title: "Learn React", tags: ["js", "react"] }, { id: 2, title: "Build app", tags: ["project"] } ]); function addTagToTodo(todoId, newTag) { setTodos(todos.map(todo => todo.id === todoId ? { ...todo, tags: [...todo.tags, newTag] } : todo )); }

map() returns a new array. The matching todo gets a new object with a new tags array. Other todos keep their original references. That matters for React.memo on list items - unchanged items won't re-render.

Complex nested updates with Immer

tsx
import { produce } from "immer"; const [state, setState] = useState({ users: [ { id: 1, name: "Alice", scores: [90, 85] } ] }); function addScore(userId, score) { setState(produce(draft => { const user = draft.users.find(u => u.id === userId); if (user) { user.scores.push(score); // Immer tracks this and produces a new object } })); }

Without Immer, this requires spreading the state object, spreading the users array, finding the user, spreading that user, and spreading the scores array. Five levels of spread for one change. Immer handles all of that internally. The intent is clear.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?