React stale closures in useEffect: the bug and three fixes
You set up an interval in a useEffect with an empty dependency array. It increments a counter every second. But the counter goes 0, 1, and sticks at 1 forever — or your logging always prints the same old value. This is a stale closure, and it is the most common intermediate-level React bug.
Why it happens (it is just JavaScript)
Every render creates new function instances that close over that render's props and state. An effect with an empty dependency array runs exactly once, on mount, so it captures the values from the first render — forever. When your interval callback later reads count, it reads the first render's count, which was 0. This is not a React quirk; it is ordinary closure semantics. React just makes it visible by re-running your component body on every render.
// STALE: the interval closes over count from render #1 — forever
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000); // always 0 + 1
return () => clearInterval(id);
}, []); // lint warns: missing dependency 'count'Fix 1: the functional updater
setCount(c => c + 1) does not need to read count at all — React hands you the current value. This lets you keep an empty dependency array honestly. It is the right fix for intervals and subscriptions that only need to write state.
useEffect(() => {
const id = setInterval(() => setCount(c => c + 1), 1000);
return () => clearInterval(id);
}, []);Fix 2: add the dependency
If the effect genuinely uses count, list it. The effect re-subscribes when count changes. This is what the exhaustive-deps lint rule is telling you, and it is usually the correct move. The tradeoff is that the interval is recreated on every change — fine for many cases, wasteful for a per-second timer.
Fix 3: a ref for 'read latest, do not re-subscribe'
When you want to read the newest value without re-running the effect, keep it in a ref. The ref object is stable, so the closure over it always sees the current .current. This is the useEventCallback pattern that React's own useEffectEvent formalises.
const cbRef = useRef(onTick);
useEffect(() => { cbRef.current = onTick; }); // no deps: refresh every render
useEffect(() => {
const id = setInterval(() => cbRef.current(), 1000); // always the latest
return () => clearInterval(id);
}, []); // subscribe once, but never staleThe anti-fix
Deleting the dependency array or silencing the exhaustive-deps lint rule is not a fix — it trades a stale value for an infinite loop or a subtly wrong one. If the linter complains, one of the three fixes above is the answer; disabling it almost never is.