The stale closure

medium~20 min#closures#javascript#react

This component sets up an interval that should log the current count every second. It logs 0 forever.

const [count, setCount] = useState(0);
useEffect(() => {
  const id = setInterval(() => console.log(count), 1000);
  return () => clearInterval(id);
}, []);

Explain why, and give two fixes with different trade-offs.

Solution

Why. Each render creates a new function scope. The callback passed to setInterval closes over the count binding from the render in which it was created — the first one, where count is 0. Because the dependency array is empty, the effect never re-runs, so the interval keeps calling that original closure forever. Later renders create new closures over new values, but nothing ever installs them.

The key idea, and what the interviewer is listening for: count is not a variable that changes. Each render has its own count, a separate const binding. State updates cause a new render with a new binding; they do not mutate the old one. Once that clicks, the whole family of stale-closure bugs becomes obvious.

Fix 1 — functional update. Do not read count at all:

setCount(c => c + 1);

The updater receives the current value from React, so nothing is captured. The effect can keep an empty dependency array and the interval is never torn down. This is the right fix whenever the new value is derived from the old.

Fix 2 — a ref holding the latest value.

const countRef = useRef(count);
countRef.current = count;
useEffect(() => {
  const id = setInterval(() => console.log(countRef.current), 1000);
  return () => clearInterval(id);
}, []);

A ref is a stable mutable box that survives renders, so reading .current always sees the latest value. Use this when you need to read fresh state inside a long-lived callback without re-subscribing. The cost is that mutating a ref does not trigger a render, so it must not hold anything the UI displays.

Fix 3, and why it is usually wrong. Add count to the dependency array. It works, but the interval is now destroyed and recreated on every count change, so the timer restarts each second and never fires at a consistent moment. Correct value, broken timing — a good example of a fix that passes review and misbehaves.

The follow-up they will ask

When would you reach for a ref over a functional update, given both fix this?