React 7,897 decides whether to re-render by comparing the new state with the old using Object.is. Mutate an object and hand back the same reference and the comparison says "unchanged", so nothing happens on screen although the data is different. Every update has to produce a new object.
const [form, setForm] = useState({ name: 'Ada', address: { city: 'London' } });
form.name = 'Grace'; // mutation: still the same object...
setForm(form); // ...so React bails out and nothing renders
setForm({ ...form, name: 'Grace' }); // a shallow copy is enough for one level
setForm(f => ({ // copy every level you actually change
...f, address: { ...f.address, city: 'Cambridge' }
}));The spread is shallow: { ...form } copies address by reference, so changing form.address.city in place would still mutate shared data. Arrays follow the same rule, and the methods that read most naturally are exactly the ones that mutate.
| Operation | Avoid | Replacement |
|---|---|---|
| Append | push, unshift | [...items, next] |
| Remove | splice, pop | items.filter(i => i.id !== id) |
| Change one | items[i] = next | items.with(i, next) |
| Insert | splice | items.toSpliced(i, 0, next) |
| Reorder | sort, reverse | toSorted, toReversed |
with, toSorted, toReversed and toSpliced are the ES2023 copying methods, in every current browser and in Node since version 20. Where the shape is deep enough that spreads become unreadable, reach for Immer 28,983 (immerjs/immer (https://github.com/immerjs/immer 28,983 ), npm 2,036 i immer use-immer): its produce hands you a proxy you may mutate freely and returns a new object built by structural sharing, so untouched branches keep their identity and memoization keeps working.