Replace, Don't Mutate
Immutable Updates
Updating state means giving React a NEW object or array. Mutating the existing one in place won't trigger a re-render.
What you'll learn
- Update arrays without mutation
- Update nested objects without mutation
- Recognize the mutate-and-set bug
React uses Object.is to decide whether state changed. If you
mutate an existing object and pass it back, the reference is the
same — React skips the re-render. You’ll see your state update in
DevTools, but the UI won’t move.
The rule: always give the setter a NEW object or array.
Arrays — Replace, Don’t Mutate
| Operation | ✗ Mutating | ✓ Immutable |
|---|---|---|
| Add to end | arr.push(x) | setArr([...arr, x]) |
| Add to start | arr.unshift(x) | setArr([x, ...arr]) |
| Remove by index | arr.splice(i, 1) | setArr(arr.filter((_, idx) => idx !== i)) |
| Remove by id | — | setArr(arr.filter(item => item.id !== id)) |
| Update by id | arr[i].x = … | setArr(arr.map(item => item.id === id ? { ...item, x: … } : item)) |
| Insert at index | arr.splice(i, 0, x) | setArr([...arr.slice(0, i), x, ...arr.slice(i)]) |
| Sort | arr.sort() | setArr([...arr].sort(...)) |
.map, .filter, .slice, .concat, spread — all return new
arrays. .push, .pop, .splice, .sort, .reverse mutate in
place. Stick to the new-array ones.
Objects — Spread, Don’t Assign
// ✗ Mutating — no re-render
user.name = "Ada";
setUser(user);
// ✓ New object
setUser({ ...user, name: "Ada" }); Nested Objects — Spread Every Level
The trickiest case. To update a nested field, spread every level on the path:
const [state, setState] = useState({
user: { name: "Ada", address: { city: "London" } }
});
setState({
...state,
user: {
...state.user,
address: { ...state.user.address, city: "Bath" }
}
}); The hassle of all that spreading is why people reach for:
- Immer — write mutation-style code; it produces an immutable result under the hood
useReducer— described in chapter 5, helpful for complex shapes- Flatter state design — sometimes the cleanest fix is just to avoid deep nesting
With Immer
import { produce } from "immer";
setState(produce(draft => {
draft.user.address.city = "Bath"; // looks like a mutation; isn't
})); Immer hands you a “draft”, lets you mutate it, then produces a new immutable object. Popular with React + Redux.
Why React Cares
State updates trigger re-renders. React skips renders when the new
state is Object.is-equal to the old. Mutating an object in place
keeps the reference identical, so React sees nothing changed.
A new object with the same fields is a different reference → React re-renders → UI updates.
Up Next
A few more common state pitfalls collected in one place.
State Pitfalls →