React State Is Not Updating After Your Click - Here Is Why
6 min read
“Your mutation succeeded. The data is correct in memory. React just never knew. It checked the reference, found the same address as before, and decided nothing had changed. That is the whole bug.”
React does not re-render after every setState call automatically. It checks first:
is this the same state as before?
For objects and arrays, "same" means same memory address. Not same contents. Same
address. React uses Object.is for this check, and Object.is on two objects
compares where they live in memory, not what they contain.
Two objects with identical fields still fail the check if they were created
separately. And the same object always passes - no matter what you changed inside it.
function handleVote(id) { const target = options.find((o) => o.id === id); target.score += 1; // you changed the contents setOptions(options); // the address is still the same}
React calls Object.is(prevOptions, options). Same array reference. Returns true.
No re-render. UI stays frozen.
The score incremented correctly in memory. React just did not schedule a repaint
because from its perspective, nothing changed.
Symptom
The handler fires. console.log confirms it. But the display does not update.
Sometimes it "catches up" randomly after clicking something unrelated - that is
another state update accidentally triggering the re-render your mutation should
have caused.
A shallow copy creates a new array reference - but the objects inside are still
the same references from the original.
const copy = [...items];copy[0].name = "new name"; // still mutates the original item objectsetItems(copy);
The parent re-renders. But any memoized child receiving copy[0] as a prop will
not, because the item reference did not change. Fix both levels:
setItems(items.map((item, i) => i === index ? { ...item, name: "new name" } : item));
New array from map. New object from the spread. Every changed level has a fresh
reference.
The Sensei's Hint
One rule to check yourself: if you are reading from state, changing a field on
it, and passing it back - you are mutating. The fix is always the same shape:
spread into a new object at every level that changed.
The mutation works. The data is right. That is exactly what makes this hard to
spot.
The bug only surfaces when something eventually triggers a re-render for another
reason - an unrelated state update, a parent re-render - and the component suddenly
shows all the accumulated mutations at once. It looks like the UI fixed itself.
Then breaks again.
If your display is one click behind, or shows the right values after an unrelated
interaction, mutation is almost certainly the cause.
Not every "I clicked and nothing happened" bug is this bug. This one produces
the exact same symptom - click Toggle, nothing visibly changes - with no
mutation anywhere in sight. setUserPrefs returns a brand new object every
time, Object.is correctly sees a change, React correctly re-renders. The
screen still doesn't move.
In object spread syntax, the rightmost source wins on any key both objects
share. { ...userPrefs, ...DEFAULTS } spreads userPrefs first and
DEFAULTS on top of it - so DEFAULTS.theme ("dark") overwrites
userPrefs.theme ("light") every single time, regardless of what
userPrefs.theme actually holds.
On mount, active.theme is "dark" - wrong, the saved preference is "light".
Click Toggle: setUserPrefs correctly flips userPrefs.theme to "dark".
React re-renders. active.theme is... still "dark", same as before the
click, because DEFAULTS.theme was overwriting it both times. The state
changed. The screen shows the same thing it always did.
The Sensei's Hint
None of the reference fixes above apply here - there is no stale reference
to fix. setUserPrefs({ ...p, theme: ... }) already creates a new object
every time. The bug is which object's value survives the merge, not
whether a new object was created.
Flip the order. DEFAULTS is spread first, supplying a value for every
key - including ones userPrefs never sets, like fontSize and
notifications. userPrefs is spread last, so any key it does define
overrides the default underneath it. active.theme now correctly tracks
userPrefs.theme, on mount and after every toggle.
Constraints
When merging overrides into a set of defaults, the defaults go first and
the overrides go last: { ...DEFAULTS, ...overrides }. Read a spread left
to right as "start with this, then let this win" - reversing the order
makes the defaults permanent and the overrides decorative.
The katas below each isolate this bug in a real scenario. The voting panel is the
cleanest entry point - one click, one mutation, one completely frozen score. Work
through that one first, then the profile editor, then the reducer kata once the
pattern is solid. Settings Overwritten by Defaults is last on purpose - it produces
the same "click does nothing" symptom with zero mutation involved, which is a good
check on whether you're diagnosing by symptom or by actual cause.