Immutable State Updates
Reviewed & published by Brayan K
Immutable state updates mean never changing a state value in place — instead you create a new array or object with the change applied. React compares state by reference, so a brand-new value is how it detects a change and triggers a re-render; mutating the existing value silently skips it.
Part of the free React course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Why mutating state skips the re-render
- Copy with the spread operator before changing
- Add, remove, and update array items immutably
- Update nested objects level by level
- Why spread is a shallow copy
- Build a small immutable reducer
1️⃣ Why Mutation Skips the Re-render
React's state setter checks whether the new value is the same reference as the old one (an Object.is comparison). If you push into the existing array or assign to obj.x, the reference is unchanged, so React concludes nothing happened and does not re-render. Creating a new value flips that comparison to "changed."
// ❌ Mutation — same reference, no re-render
function addBad(item) {
items.push(item); // mutates the existing array
setItems(items); // same reference -> React bails out
}
// ✅ New array — React sees a change
function addGood(item) {
setItems([...items, item]); // brand-new array
}// React compares by REFERENCE. Mutating keeps the same reference,
// so React thinks nothing changed and skips the re-render.
const original = [1, 2, 3];
const mutated = original;
mutated.push(4); // ❌ same array
console.log(original === mutated); // true -> React: "no change"
const copied = [...[1, 2, 3], 4]; // ✅ brand-new array
console.log([1, 2, 3].length, copied.length); // 3 4
console.log([1, 2, 3] === copied); // false -> React: "changed!"2️⃣ Immutable Array Updates
Three moves cover almost everything, and each returns a new array: add with spread [...items, x] or concat; remove with filter; update one item with map, replacing only the matching element. Avoid push, splice, sort, and reverse on state — they mutate in place.
// In a component these go through setState:
setTodos([...todos, newTodo]); // add
setTodos(todos.filter(t => t.id !== id)); // remove
setTodos(todos.map(t => t.id === id ? { ...t, done: true } : t)); // update// Immutable ARRAY updates always return a NEW array:
const todos = ["a", "b", "c"];
const added = [...todos, "d"]; // ADD
const removed = todos.filter(t => t !== "b"); // REMOVE
const updated = todos.map(t => (t === "a" ? "A!" : t)); // UPDATE one
console.log(added.join(",")); // a,b,c,d
console.log(removed.join(",")); // a,c
console.log(updated.join(",")); // A!,b,c
console.log(todos.join(",")); // a,b,c <- original untouchedYour turn — add and update without mutating:
// 🎯 YOUR TURN — fill in each ___ then press "Try it Yourself".
// 1) ADD an item immutably with the spread operator.
// 2) UPDATE the matching item's "done" to true using map.
const list = [{ id: 1, done: false }, { id: 2, done: false }];
const withNew = [___list, { id: 3, done: false }]; // 👉 ...
const toggled = list.___(item => // 👉 map
item.id === 1 ? { ...item, done: true } : item
);
console.log(withNew.length); // 3
console.log(toggled[0].done); // true
console.log(list[0].done); // false
// ✅ Expected output:
// 3
// true
// false3️⃣ Nested Objects & Shallow Copies
Spread makes a shallow copy: the top level is new, but nested objects are still shared. So to change something deep, you must spread every level along the path you are editing, leaving untouched branches shared (which is fine and efficient). Skip a level and you will accidentally mutate the original's nested object.
// Update state.user.prefs.theme without mutating anything:
setState({
...state,
user: {
...state.user,
prefs: { ...state.user.prefs, theme: "dark" },
},
});
// Only the user -> prefs branch is copied; count, etc. are shared.// Nested OBJECT update: spread each level along the path you change.
const state = { user: { name: "Ada", prefs: { theme: "light" } }, count: 0 };
const next = {
...state,
user: {
...state.user,
prefs: { ...state.user.prefs, theme: "dark" }, // only this leaf changes
},
};
console.log(next.user.prefs.theme); // dark
console.log(state.user.prefs.theme); // light <- original safe
console.log(next.count === state.count); // true <- unchanged branch shared🧩 Reorder Challenge
These lines immutably toggle one todo's done flag and save it. Put them in the right order:
A. t.id === id ? { ...t, done: !t.done } : t
B. function toggle(id) {
C. setTodos(next);
D. const next = todos.map(t =>
E. );Common Errors (and the fix)
- UI does not update after push. — Same reference. Use [...items, x] and pass the new array.
- Mutating with sort/reverse. — They mutate in place. Copy first: [...items].sort(...).
- Editing a nested object directly. — state.user.name = x mutates. Spread each level on the path.
- Assuming spread is a deep copy. — It is shallow; nested refs are shared. Spread every changed level.
📋 Quick Reference
| Operation | Immutable way |
|---|---|
| Add to array | [...items, x] |
| Remove from array | items.filter(...) |
| Update one item | items.map(...) |
| Change object field | { ...obj, x: v } |
| Nested change | spread each level |
🧠 Quick Recall
1. Why does push + setState often do nothing?
It mutates the same array, so the reference is unchanged and React bails out of the re-render.
2. How do you remove an item immutably?
With filter, which returns a new array excluding the item.
3. Is spread a deep copy?
No, it is shallow. Spread every nested level you intend to change.
Mini-Challenge: Immutable Cart Reducer
Build a reducer that adds, removes, and updates cart items without ever mutating the previous state. Run it and check your output.
// 🎯 MINI-CHALLENGE: a tiny immutable reducer for a cart
//
// Implement reduceCart(state, action) WITHOUT mutating state:
// { type: "add", item } -> append item
// { type: "remove", id } -> drop item by id
// { type: "qty", id, qty } -> set that item's qty
// Then run the three actions in order and log the final names+qty.
//
// ✅ Expected output:
// apple x2, pear x1
const start = { items: [{ id: 1, name: "apple", qty: 1 }] };
// your code here🎉 Lesson Complete
- ✅ React compares state by reference — mutation is invisible to it
- ✅ Copy with spread before you change anything
- ✅ Arrays: spread to add, filter to remove, map to update
- ✅ Nested objects: spread each level on the path (spread is shallow)
Practice quiz
What does it mean to update React state immutably?
- Change the existing value in place
- Freeze the state so it can never change
- Create a new array or object with the change applied, instead of mutating the old one
- Use a global variable instead of state
Answer: Create a new array or object with the change applied, instead of mutating the old one. Immutable updates produce a brand-new value rather than modifying the existing one in place.
How does React decide whether state changed?
- A reference comparison (Object.is) of the new value vs the old
- A deep equality check of every field
- By hashing the value
- By comparing the rendered HTML
Answer: A reference comparison (Object.is) of the new value vs the old. React compares by reference with Object.is; a same-reference value looks unchanged and skips the re-render.
Why does calling array.push(x) then setItems(array) often render nothing?
- push is too slow
- setItems ignores arrays
- push throws an error in React
- push mutates the same array, so the reference is unchanged and React bails out
Answer: push mutates the same array, so the reference is unchanged and React bails out. push mutates in place, leaving the same reference, so React sees no change and skips the update.
Which is the immutable way to ADD an item to a state array?
- items.push(x)
- [...items, x]
- items[items.length] = x
- items.append(x)
Answer: [...items, x]. Spreading into a new array, [...items, x], adds an item without mutating the original.
Which method immutably REMOVES an item from an array?
- filter
- splice
- pop
- shift
Answer: filter. filter returns a new array excluding the unwanted item; splice/pop/shift all mutate in place.
Which method immutably UPDATES one item in an array?
- forEach
- sort
- map
- reverse
Answer: map. map returns a new array, replacing only the matching element and copying it with the spread.
Is the spread operator a deep copy?
- Yes, it copies all nested levels
- No, it is a shallow copy — nested objects are still shared references
- Only for arrays
- Only for objects
Answer: No, it is a shallow copy — nested objects are still shared references. Spread copies only the top level; nested objects remain shared, so you must spread each level you change.
To change state.user.prefs.theme immutably, what must you do?
- Just set state.user.prefs.theme = 'dark'
- Replace the entire state with a primitive
- Call setState twice
- Spread each level along the path: { ...state, user: { ...state.user, prefs: { ...state.user.prefs, theme } } }
Answer: Spread each level along the path: { ...state, user: { ...state.user, prefs: { ...state.user.prefs, theme } } }. Because spread is shallow, you copy every level along the path you edit; untouched branches can stay shared.
Which array methods should you avoid calling directly on state because they mutate?
- map and filter
- sort, reverse, push, and splice
- concat and slice
- join and indexOf
Answer: sort, reverse, push, and splice. sort, reverse, push, and splice mutate the array in place; copy first (e.g. [...items].sort()).
When you spread an unchanged nested branch's reference into new state, that is...
- A bug that causes stale data
- Only allowed in React 19
- Fine and efficient — sharing untouched branches is expected
- Going to cause an infinite loop
Answer: Fine and efficient — sharing untouched branches is expected. Sharing references for branches you did not change is correct and efficient; only copy along the edited path.
Continue this course
- Previous: How React Rendering Works
- Next: Styling in React — className vs inline styles, CSS Modules, and conditional classes
- Quick reference: React cheat sheet
Frequently asked questions
Why can't I just push to a state array?
Because push mutates the SAME array. React compares the new value to the old by reference; if it is the same array, React sees no change and skips the re-render. You must create a NEW array.
How do I add or remove an array item immutably?
Add with the spread [...items, newItem] or concat. Remove with filter so it returns a new array without the item. Update one item with map, replacing only the matching element.
How do I update a nested object without mutating it?
Spread at each level you change: { ...state, user: { ...state.user, name } }. Only copy the branches along the path you are editing; untouched branches can be shared.
Does spread make a deep copy?
No. Spread is a shallow copy — the top level is new, but nested objects are still shared references. That is why you must spread each nested level you intend to change.