The bug that feels impossible
You click Start, increment a counter several times, and a timer still logs zero. React did not lose your update. The timer is running a function created during an earlier render, and that function closes over that render's values.
Each render is a snapshot. A handler rendered when count was zero keeps access to count from that snapshot, even after React produces newer snapshots.
function Counter() {
const [count, setCount] = useState(0);
function start() {
setInterval(() => console.log(count), 1000);
}
return <button onClick={start}>{count}</button>;
}Why dependency arrays do not magically fix it
An effect dependency list tells React when to replace an effect. It does not make an already-created timeout, subscription, or promise callback read a newer render. Adding every value can be correct, but it can also reconnect a websocket or restart a timer unnecessarily.
The right question is not 'how do I stop ESLint complaining?' It is 'should this external process be recreated when this value changes, or should it read the latest value?'
- Recreate the process when its configuration changed.
- Use a functional state update when the next value depends on the previous one.
- Use a ref only when an external callback genuinely needs the latest value without being recreated.
Three fixes, three different jobs
For counters, a functional update removes the captured value entirely. For an interval that must read current state, keep the current value in a ref. For a fetch, cancel or ignore the old request so an older response cannot overwrite newer UI.
// Previous state is supplied by React.
setCount(previousCount => previousCount + 1);
const latestQuery = useRef(query);
useEffect(() => {
latestQuery.current = query;
}, [query]);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${query}`, { signal: controller.signal });
return () => controller.abort();
}, [query]);A debugging workflow
Log a render identifier alongside the value. If a callback logs an older identifier, you are looking at a closure from an older render. Then identify its lifetime: it may be an event listener, a delayed task, an async continuation, or a callback handed to another library.
Do not paper over the issue with useCallback. Memoization stabilizes a function identity; it can preserve an old closure for longer when its dependencies are wrong.
Key Takeaways
- A render is a snapshot, not a mutable object that callbacks automatically follow.
- Functional updates are the cleanest solution when deriving next state from previous state.
- Use refs and cancellation deliberately for callbacks that outlive the render that created them.
Ready to explain this under interview pressure?
Learn the mental models, then practise applying them to realistic frontend problems.
Join Cohort 3 Waitlist