Calling a state setter does not change the variable you are holding. It schedules a re-render, and the new value appears in the next one. Almost every confusing state bug comes from expecting otherwise.
State is a snapshot
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
console.log(count); // still 0 — even though we just "set" it to 1
}
return <button onClick={handleClick}>{count}</button>;
}count is a const belonging to this render. Nothing can change
it, and React does not try to. What setCount does is tell React "next time you render
this component, hand it 1". The current render finishes with the value it started with.
This is not a quirk to work around; it is what makes React predictable. Everything a render sees is frozen for the duration of that render, so a handler cannot read a value that is half-updated.
The consequence: batching
function handleClick() {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
}Starting from 0, this sets the count to 1, not 3. Every count in
that function is the same frozen 0, so all three calls say "make it 1". React then
performs one re-render for the whole handler, rather than three.
The updater function
Pass a function instead of a value and React calls it with the latest pending state, queuing your change on top of the previous one:
function handleClick() {
setCount((c) => c + 1);
setCount((c) => c + 1);
setCount((c) => c + 1); // now it really is 3
}The rule: if the new value depends on the old one, use the updater form. It costs nothing and it is never wrong.
It matters most when the "old value" is not visible at the call site. The toast provider adds a
toast, and three seconds later removes it — by which time toasts from the original
render is long stale, and the array may hold entirely different messages:
const showToast = useCallback((message: string, variant: ToastVariant = 'success') => {
const id = crypto.randomUUID();
setToasts((current) => [...current, { id, message, variant }]);
window.setTimeout(() => {
setToasts((current) => current.filter((toast) => toast.id !== id));
}, 3000);
}, []);Written as setToasts([...toasts, newToast]) this would be broken in two ways: firing
two toasts quickly would lose the first, and the timeout would restore a snapshot of the list from
three seconds ago. The updater form has no such problem, and it is also why the
useCallback around it can have an empty dependency
array — it never reads toasts.
Same shape, toggling a topping on and off:
function toggleTopping(id: string) {
setSelectedToppingIds((current) =>
current.includes(id) ? current.filter((t) => t !== id) : [...current, id],
);
}Never mutate
React decides whether to re-render by comparing the new state to the old one by reference. Change an object in place and the reference is identical, so React concludes nothing happened.
// WRONG. The array is now different, but it is the SAME array, so nothing re-renders.
items.push(newItem);
setItems(items);
// Right — a new array.
setItems([...items, newItem]);The mutating array methods to avoid: push, pop, shift,
unshift, splice, sort, reverse. The last two
catch people out because they read like they return a new array — they do not, they sort in place and
return the same reference. Copy first: [...items].sort(…).
Objects
// WRONG
user.fullName = 'Folau';
setUser(user);
// Right
setUser({ ...user, fullName: 'Folau' });Arrays of objects
This is the case that actually comes up, and the one worth memorising. Changing one item in a
list means map — a new array, with a new object in one position and the originals
everywhere else:
case 'SET_QUANTITY': {
// Dropping to zero removes the line — it is what a user expects from a "−" button.
if (action.payload.quantity <= 0) {
return {
...state,
items: state.items.filter((item) => item.lineId !== action.payload.lineId),
};
}
return {
...state,
items: state.items.map((item) =>
item.lineId === action.payload.lineId
? { ...item, quantity: action.payload.quantity }
: item,
),
};
}Read the map carefully: the matching item is replaced by a copy with a new quantity,
and every other item is returned as is. Untouched items keep their identity, which is what
lets React.memo skip re-rendering them.
The three operations, all non-mutating:
// add
items: [...state.items, action.payload]
// remove
items: state.items.filter((item) => item.lineId !== action.payload.lineId)
// update one
items: state.items.map((item) =>
item.lineId === target ? { ...item, quantity: next } : item,
)Nesting
Spread is shallow. Updating something two levels down means copying each level on the way:
setOrder({
...order,
address: { ...order.address, city: 'Salt Lake City' },
});If you are writing three levels of that, the state shape is the problem, not the syntax. Flatten it, or use a library like Immer that lets you write the mutation and produces the copy for you — Redux Toolkit bundles Immer for exactly this reason.
Why not just mutate and force an update?
Because immutability is what buys you the rest of React. Reference comparison is how
memo, useMemo and useCallback decide whether to skip work; it
is how effect dependency arrays detect change; it is what makes a reducer testable and time-travel
debugging possible. Mutating one object in one place quietly switches all of that off.
Next
Forms and Controlled Inputs — state, applied to every input on the page.