Back to the Library

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.”

In This Post

What React Checks Before It Re-rendersThe Exact MistakeHow to Fix ObjectsHow to Fix ArraysThe Two-Level TrapThe Same Bug in useReducerWhy This Bug Hides So WellA Lookalike Bug: Spread Order, Not MutationThe Fix: Defaults First, Overrides LastPractice These Patterns

Practice This Pattern

White Belt

Score Won't Update

A voting panel where clicking Vote does nothing - scores are mutated in place and the same array reference is passed back to setState.

+10 KI
Enter the Dojo
White Belt

Name Edit Does Nothing on Screen

A profile editor where typing in the name field has no visible effect - direct object mutation passes the same reference back to setState.

+10 KI
Enter the Dojo
Black Belt

Completing a Task Has No Effect

A useReducer todo list where clicking Complete produces no change - the reducer mutates state and returns the same reference it received.

+50 KI
Enter the Dojo
White Belt

Settings Overwritten by Defaults

A settings panel that shows Theme: dark even though the saved preference is light, and where Toggle has no visible effect - DEFAULTS is spread after userPrefs, so it always wins on shared keys.

+10 KI
Enter the Dojo
BugDojo
BlogFAQ

© 2026. Carved in code.

Your button fires. The console.log right before setState confirms the code ran. But the screen did not update. Nothing moved.

You have a mutation bug. And the frustrating part is that your data is actually correct - React just has no idea.

What React Checks Before It Re-renders

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.

The Exact Mistake

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.

How to Fix Objects

Create a new object. Never modify the existing one.

// broken - same reference passed back
profile.name = e.target.value;
setProfile(profile);
 
// fixed - new object, new reference
setProfile({ ...profile, name: e.target.value });

The spread copies every field into a fresh allocation. Object.is returns false. React re-renders. The display updates.

How to Fix Arrays

Same rule. The three mutations developers reach for most often, and what to write instead:

Adding an item:

// broken
items.push(newItem);
setItems(items);
 
// fixed
setItems([...items, newItem]);

Updating one item:

// broken
items[index].done = true;
setItems(items);
 
// fixed
setItems(items.map((item, i) =>
  i === index ? { ...item, done: true } : item
));

Removing an item:

// broken
items.splice(index, 1);
setItems(items);
 
// fixed
setItems(items.filter((_, i) => i !== index));

Note that Array.sort also mutates in place. Always spread before sorting: [...items].sort(compareFn).

The Two-Level Trap

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 object
setItems(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 Same Bug in useReducer

Reducers follow the same contract. Return a new object whenever something changed. Same reference returned equals no update, no exceptions.

// broken - mutates and returns the same reference
case "TOGGLE":
  state.items[action.index].done = !state.items[action.index].done;
  return state;
 
// fixed - new object at every changed level
case "TOGGLE":
  return {
    ...state,
    items: state.items.map((item, i) =>
      i === action.index ? { ...item, done: !item.done } : item
    ),
  };
Constraints

Never modify the state argument inside a reducer. If the reference you return equals the reference you received, React discards the update entirely.

Why This Bug Hides So Well

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.

A Lookalike Bug: Spread Order, Not Mutation

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.

const DEFAULTS = { theme: "dark", fontSize: 16, notifications: true };
 
const [userPrefs, setUserPrefs] = useState({ theme: "light" });
const active = { ...userPrefs, ...DEFAULTS };

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.

The Fix: Defaults First, Overrides Last

const active = { ...DEFAULTS, ...userPrefs };

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.

Practice These Patterns

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.