React setState Does Nothing - Same Value, No Re-render
5 min read
“The click registers. The function runs. setState gets called, with an argument, no typos, no errors. The number on screen does not move - because React looked at what you sent and decided nothing actually changed.”
You click a button labeled "Increment." The click handler runs - put a
console.log at the top and it fires every time. Inside, it calls
setCount(...) with an argument that is, by every reasonable reading,
correct.
The number on screen does not change. Not the first click, not the tenth.
There's no error. No warning. setState was called, with a value, and
React just... didn't do anything with it.
useState's setter doesn't blindly trigger a re-render every time it's
called. First, it compares the new value to the current value using
Object.is - the same algorithm as ===, with a couple of edge cases for
NaN and signed zero that don't matter here.
If Object.is(newValue, currentValue) is true - if the new value and the
old value are, as far as JavaScript is concerned, the same value - React
concludes nothing changed, and skips the re-render entirely. Not "renders
and shows the same thing." Skips it. The component function doesn't run
again, the JSX isn't recomputed, nothing.
This is an optimization, and most of the time it's invisible - usually when
you call a setter, you're passing something genuinely different. The bug
shows up when the value you compute and pass back happens to equal the
value already there.
handleClick calls setCount(count). Read literally, that's "set count
to... count." Whatever count currently is, pass that same thing back in.
count is 0. setCount(0). React compares Object.is(0, 0) - true.
No change, no re-render. count is still 0, so the next click calls
setCount(0) again, and the one after that, forever.
Symptom
The click handler runs every time - confirmed, no typos in the event
wiring, no missing onClick. The displayed number simply never changes,
on the first click or any subsequent one.
setCount(count + 1) passes 0 + 1 = 1 the first time. Object.is(1, 0)
is false - genuinely different - so React proceeds with the re-render.
count becomes 1, the component re-renders, Count: 1 appears, and the
next click computes 1 + 1 = 2.
The fix isn't "make setCount work harder." It's "give it a value that
isn't equal to what's already there" - which an increment naturally does,
and a same-value passthrough naturally doesn't.
The first example is the bug in its most obvious form - setCount(count)
practically announces itself. This one hides the exact same mechanism
behind a "Refresh" button.
The design intent is reasonable: clicking Refresh should change
refreshKey, which the effect is watching, which should make the effect
re-run and update the timestamp.
But handleRefresh calls setRefreshKey(refreshKey) - the current value,
passed straight back. Object.is(refreshKey, refreshKey) is trivially
true. React skips the re-render. Because there's no re-render, the effect
that depends on refreshKey doesn't get a chance to re-evaluate either -
both the render and the effect that was supposed to follow from it are
skipped in one stroke. "Last refreshed: Never" stays exactly that, no matter
how many times Refresh is clicked.
The Sensei's Hint
This version is harder to spot than the counter because refreshKey
itself is never displayed - it exists purely to trigger something else.
When a piece of state's only job is "change, so that something downstream
notices," passing its current value back in defeats that entire job
silently.
function handleRefresh() { setRefreshKey((prev) => prev + 1);}
(prev) => prev + 1 is a functional updater - it receives whatever
refreshKey currently is and returns one more than that. 0 becomes 1,
1 becomes 2, and so on. Every call produces a value that is, by
construction, different from the one before it. Object.is always returns
false, the re-render always happens, and the effect watching refreshKey
always gets to run.
Notice that refreshKey's actual numeric value is irrelevant to anything
the user sees - it exists only as a "this changed, so re-check" signal. A
functional updater that always increments is the standard way to build that
kind of signal.
Constraints
Before calling a state setter, ask: "is there any path through this code
where the new value could equal the current value?" If the new value is
computed from the current value via something that can produce the same
result - reading the same variable back, a calculation that can land on the
same number - Object.is will catch it and silently skip the update. An
increment, a toggle via functional updater, or a value from a genuinely
different source (user input, a fetch result) doesn't have this problem.
"Counter Never Updates" is the bug at its smallest - one line,
setCount(count), nothing else going on. Get comfortable diagnosing it
here, where the cause and the symptom are right next to each other. "Force
Refresh Does Nothing" is the same line of code, setRefreshKey(refreshKey),
but the consequence cascades into a useEffect that depends on the value -
work through it to see how "skip the re-render" can quietly take "skip the
effect that was supposed to run" down with it.