← Bank
Code Review

Stale Closure in React

Code ReviewMid~9m
reactclosureshooks

Prompt

A teammate filed a bug: "the live counter on the dashboard logs the same number forever even though the on-screen count goes up." This Timer component is the suspect. Review it and the bug together — form a hypothesis about the root cause before you reach for a fix.

function Timer() {
  const [count, setCount] = React.useState(0);

  React.useEffect(() => {
    const interval = setInterval(() => {
      console.log(count);
    }, 1000);
    return () => clearInterval(interval);
  }, []);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

How this round runs

State your hypothesis for why the logged value is stuck before changing any code, then separate the root cause from the symptom. Tell me what the fix is and what trade-off it introduces.

Model answer

Hypothesis, stated before touching code: this is a stale closure over count, and the giveaway is that the on-screen Count updates (so state and re-render are fine) while only the logged value is stuck. That split points squarely at the interval callback, not at the render.

Why it happens (root cause, not symptom): the effect has an empty dependency array, so it runs exactly once, on mount. The setInterval callback created then closes over the count binding from that first render — value 0. Clicking Increment updates state and re-renders, but because the dependency array is [], the effect never re-runs, the old interval keeps ticking, and its callback keeps reading the count it captured: 0, forever. The symptom is "logs 0"; the root cause is "the effect captured a stale binding and was told never to refresh it."

I'd confirm the hypothesis cheaply before committing: log count in render too — it will climb 0,1,2… while the interval still prints 0, proving the divergence is in the captured closure, not in state. The empty [] is the smoking gun.

Two fixes, with the trade-off named:

React.useEffect(() => {
  const interval = setInterval(() => {
    console.log(count);
  }, 1000);
  return () => clearInterval(interval);
}, [count]);

Adding count to the deps re-runs the effect on every change so the closure is always current — but it tears down and recreates the interval on every tick of the counter, which resets the timer's phase and is wasteful if count changes often. If you want a single stable interval, read the latest value through the functional updater instead:

React.useEffect(() => {
  const interval = setInterval(() => {
    setCount((prev) => {
      console.log(prev);
      return prev;
    });
  }, 1000);
  return () => clearInterval(interval);
}, []);

setCount((prev) => …) always receives the current state regardless of what the closure captured, so one interval lives for the component's lifetime. (A useRef mirroring count is the third option when you need the value but not a state update.)

What I ruled out: it's not a state-update bug (the visible count increments), not a re-render bug (the <p> reflects new values), and not a setState-batching issue. It's specifically the captured binding plus the [] that pins it.

Signals — what a strong answer shows
  • Stated the stale-closure hypothesis before editing, and justified it from the symptom (screen updates, log doesn't)
  • Separated root cause (effect captured the count binding + [] never refreshes it) from symptom (logs 0)
  • Proposed a cheap confirmation (log count in render to show the divergence) instead of guessing
  • Gave both fixes and named the trade-off: [count] recreates the interval each tick vs the functional updater keeping one stable interval
  • Explicitly ruled out state/render/batching as the cause
Follow-ups — where it goes next
  • Why doesn't the on-screen count have the same problem? → JSX reads count fresh on each render; the interval callback is frozen from mount.
  • What does the lint rule react-hooks/exhaustive-deps say here? → It flags the missing count dep — that warning is exactly this bug.
  • When would you reach for useRef instead? → When you need the latest value inside the interval without triggering a state update or recreating the timer.