You called setPerson(person) after updating a property. The component didn't rerender. Now you're staring at the screen, refreshing the app, wondering if React is broken.
It's not. You mutated the state object directly, and React never noticed. Here's exactly why that happens and three ways to fix it.
- Open your component and find any place you do
state.property = newValue - Replace it with
setState({ ...state, property: newValue }) - For nested objects, spread at every level:
setState({ ...state, nested: { ...state.nested, key: newValue } }) - For arrays, use
map(),filter(), or spread. Neverpush()orsplice()on the existing array.
Why React Checks References, Not Values
React compares state using Object.is() the same equality check as === for objects. It doesn't walk through every property and compare them one by one. That would be prohibitively slow in large component trees.
What it does instead: it checks whether the reference (the memory address) of the state value changed. If you hand React the same object back after mutating it, the reference is identical. React concludes nothing changed and skips the rerender.
const [person, setPerson] = useState({ name: 'Alice', age: 25 });
// This mutates the same object React already holds
person.age = 26;
setPerson(person); // Same reference, React ignores itThis is intentional. It makes React fast. It also means the burden of immutability falls on you.
The Mutation Trap: What's Actually Happening
This part is almost never documented clearly: you can mutate the object all day before calling the setter, and React will never know. The state it holds is already pointing to the mutated version, but it never triggers a rerender because the reference didn't change.

The fix is to always give React a new object.
Fix 1: Spread Operator
The spread operator creates a shallow copy of the object, which means a new reference:
const [person, setPerson] = useState({ name: 'Alice', age: 25 });
// This creates a new object, new reference, React rerenders
setPerson({ ...person, age: 26 });{ ...person, age: 26 } copies all properties from person into a new object literal, then overwrites age. React gets a different reference, detects the change, and rerenders.
Fix 2: Nested Objects
Nested objects are where developers get caught twice. A shallow copy only creates a new reference at the top level. The nested object still shares its reference with the previous state.
const [user, setUser] = useState({
name: 'Alice',
address: { city: 'Berlin', zip: '10115' }
});
// Wrong: address.city mutated directly, nested reference unchanged
user.address.city = 'Munich';
setUser({ ...user }); // New top-level ref, but address is still the old object
// Correct: spread at every level you're modifying
setUser({ ...user, address: { ...user.address, city: 'Munich' } });You have to spread at every level of nesting you touch. It gets verbose fast, which is why Immer exists (more on that below).
A modern alternative for one-off deep copies is structuredClone():
const next = structuredClone(user);
next.address.city = 'Munich';
setUser(next);structuredClone creates a fully independent deep copy. It's built into modern browsers (Chrome 98+, Firefox 94+, Safari 15.4+) and Node.js 17+. It doesn't work with functions or class instances, but for plain data objects it's clean and readable.
Fix 3: Arrays Need the Same Treatment
Array state has the same rule: never mutate, always return a new array.
const [items, setItems] = useState([1, 2, 3]);
// Wrong, push mutates the existing array
items.push(4);
setItems(items); // Same reference, no rerender
// Correct, spread creates a new array
setItems([...items, 4]);Use these for common operations:
| Operation | Correct pattern |
|---|---|
| Add item | [...items, newItem] |
| Remove item | items.filter(i => i.id !== targetId) |
| Update item | items.map(i => i.id === targetId ? { ...i, ...updates } : i) |
| Sort | [...items].sort(compareFn) (sort mutates, so copy first) |
Methods that return a new array (map, filter, slice, concat) are safe. Methods that mutate in place (push, pop, splice, sort, reverse) are not.
Fix 4: Local Mutation Before setState Is Fine
React's rule is about not mutating the state object that's already in the store. If you create a fresh object locally and mutate it before passing it to the setter, that's completely fine:
const handleClick = () => {
const next = {}; // Brand new object, not from state
next.x = e.clientX;
next.y = e.clientY;
setPosition(next); // New reference, React rerenders
};This is the same as using spread, just written differently. The key: you never touch the object that React already holds.
When Spreading Gets Verbose: Immer
I hit this exact scenario while building a settings form with a deeply nested config object. Three levels of spreading for a single field change is where spread syntax stops feeling clever and starts feeling like a bug waiting to happen. That's what Immer is for.
For deeply nested state, spreading at every level becomes unmanageable:
setConfig({
...config,
server: {
...config.server,
database: {
...config.server.database,
port: 5433
}
}
});Immer solves this by letting you write mutation-style code while producing a new immutable copy under the hood:
import { useImmer } from 'use-immer';
const [config, updateConfig] = useImmer(initialConfig);
updateConfig(draft => {
draft.server.database.port = 5433; // Reads like mutation, but isn't
});Immer uses a JavaScript Proxy to track every change you make to the draft. When the function exits, it produces a structurally-shared immutable copy. Your original state is untouched, React gets a new reference.
Install with npm install use-immer. It's particularly useful in forms with many nested fields, or when state resembles a document structure.
useReducer for Complex State Logic
If your state updates involve multiple conditions or related fields changing together, useReducer is cleaner than stacking multiple setState calls:
function reducer(state, action) {
switch (action.type) {
case 'update_age':
return { ...state, age: action.age }; // Always return a new object
case 'reset':
return initialState;
default:
return state;
}
}
const [person, dispatch] = useReducer(reducer, initialState);
dispatch({ type: 'update_age', age: 26 });The immutability rule is the same, your reducer must return a new object, not a mutated one. The advantage is centralizing state logic and making complex transitions explicit.
If you're working with dark mode and theme toggles or any UI that manages multiple interacting states, useReducer often makes intent clearer than chained setState calls.
Mutating state directly is one of those bugs that produces no error, no warning, and a completely silent failure. Once you internalize that React compares references, not values, the fix becomes second nature.
Comments (0)
Sign in to comment
Report