React useEffect Only Runs Once - Why State Changes Don't Update the UI
7 min read
“The effect ran. You saw it work, once, on the first render. Then you changed something - typed, clicked, switched a tab - and the effect just sat there, holding onto whatever it captured the first time.”
You wrote a useEffect. It ran when the page loaded, and it did exactly the
right thing - filtered a list, started a ticker, showed a greeting.
Then you interacted with the page. Typed in a box. Clicked a different
option. And the thing the effect was supposed to keep up to date... didn't.
It just sat there, frozen on whatever it computed the first time.
This is the single most common useEffect bug, and it comes down to one
line: }, []).
The array at the end is not decoration. It is React's instruction for when
to run this effect again. "Run it after every render where any value in
this array is different from last time."
An empty array, [], means the list of "values that are different" is
always empty. By definition, nothing in an empty list can ever change. So
the effect runs exactly once - after the first render - and never again,
for the entire lifetime of the component.
If your effect reads a variable from component scope - state, props, a
computed value - and that variable isn't in the array, the effect doesn't
know it should care. It keeps using whatever that variable was the first
time.
On mount, query is "". The effect runs once, filters ITEMS by ""
(which matches everything), and sets results to all six items. Then it
never runs again.
Type "a". query becomes "a". The input updates - that's just a
controlled input doing its job. But the effect that builds results is not
listening. It ran once, with query locked at "", and [] tells React
there is nothing left to watch. From React's side, this makes sense - you
said "nothing changes," so nothing re-runs. From the developer's side, the
catch is that queryis changing on every keystroke, and the effect reads
it, but the empty array is a promise to React that the effect doesn't care
about query - a promise that turns out to be wrong.
Symptom
The input field updates correctly as you type - you can see your own
characters appear. The list below it does not move, no matter what you
type or how many characters you add.
One word added to the array. Now React's rule reads: "run this effect again
after any render where query is different from last time." Type a
character, query changes, the effect re-runs with the currentquery,
and results is recalculated against what you actually typed.
This one is more confusing because it does not freeze immediately. The
ticker goes from 0 to 1, then stops. Forever.
Here's why. The effect runs once, with count equal to 0. It creates a
setInterval callback - and that callback is a closure. It permanently
remembers count as it was at the moment the callback was created: 0.
One second later, the interval fires. It calls setCount(0 + 1), which is
setCount(1). React updates count to 1 and re-renders. Tick: 1.
But the interval callback itself was never recreated - [] means the effect
never re-runs, so the same closure with count frozen at 0 keeps firing.
Every second, forever, it calls setCount(0 + 1) - which is setCount(1)
again. count is already 1. Object.is(1, 1) is true. React skips the
update. The ticker is stuck.
The Sensei's Hint
Adding count to the dependency array would "fix" this in a technical
sense, but it would also tear down and recreate the interval every single
second - which defeats the point of an interval and reintroduces timing
bugs. This is the one case where the right fix isn't "add the dependency."
setCount((c) => c + 1) is a functional updater. Instead of reading count
from the closure, it asks React for whatever the current value actually is
at the moment the update runs, and returns one more than that. The closure
never needs to know what count is - so it doesn't matter that it's frozen.
[] is now correct, on purpose: this effect genuinely has nothing it needs
to watch. It sets up one interval on mount and tears it down on unmount,
and the interval itself stays correct forever because it never reads stale
state.
Same root cause as the search box, different shape. On mount, user is
Alice, the effect runs once, greeting becomes "Welcome, Alice!" - and then
[] makes sure it never runs again.
Click "Bob." setUser(u) updates user correctly - the button click works,
the state changes, you can confirm it with a console.log(user) right in
the component body. But the effect that readsuser to build greeting
already ran. It is not watching user. The greeting stays "Welcome, Alice!"
no matter who you click.
For every value your effect reads from component scope - state, props,
anything that isn't a setter function - ask: "if this changes, should the
effect run again?"
If yes, it belongs in the array. If the effect reads it but genuinely
shouldn't care when it changes (the setInterval case), don't add it - find
a way to stop reading it directly, usually with a functional updater.
Constraints
An empty dependency array is not "no dependencies, ignore this." It is a
claim: "this effect depends on nothing from the component's render scope,
ever." If your effect's body mentions a variable that came from useState
or props, and you're not using a functional updater for it, that claim is
false - and ESLint's react-hooks/exhaustive-deps rule will tell you so.
All three katas show [] doing the same thing - locking an effect's view of
the world to whatever it was on the very first render. "Search Box Has No
Effect" is the cleanest version: type, watch the input update, watch the
list not move. "Counter Freezes at 1" adds the closure wrinkle - the effect
does run repeatedly, it's just trapped reading a stale value, which is why
the functional updater fix looks different from just adding a dependency.
"Profile Panel Always Shows the First User" is the same bug as the search
box in a different shape - work through it last to confirm the pattern holds
regardless of what the effect is building.