Stale Closures in Hooks
Why your hooks sometimes 'forget' the latest state, and how to fix the most notorious bug in React.
What it is
A closure is a JavaScript feature where an inner function has access to the variables of its outer function.
In React, every render creates a brand new set of variables. A 'stale closure' happens when an asynchronous callback (like a timer, an event listener, or a useEffect) gets trapped holding onto the variables from an *old* render instead of the current one.
Why it matters
Stale closures are the most confusing, frustrating, and common source of bugs for React developers.
You might look at your screen and see count = 5, but inside your setInterval or setTimeout, if you log count, it says 0! Your function is literally living in the past.
How it happens
Imagine you write: useEffect(() => { setInterval(() => setCount(count + 1), 1000) }, []).
Because the dependency array is empty [], the effect only runs on the very first render, when count is 0.
The interval callback captures that 0. Every second, it runs setCount(0 + 1). The screen updates to 1. A second later, it runs setCount(0 + 1) again. It gets stuck at 1 forever.
The Stuck Interval
Start the intervals. Notice how the 'Stale' counter gets permanently stuck at 1, while the 'Fixed' counter successfully counts up.
Start the interval. The 'Stale' counter is permanently stuck at 1 because the interval function is trapped in the past. The 'Fixed' counter bypasses the closure entirely.
Check yourself
Pick an answer to lock it in, then read why. Getting one wrong is part of how it sticks.
Remember this
- A stale closure happens when a callback captures variables from an old render.
- It almost always happens in
useEffect,setInterval, or event listeners. - If you lie about your dependency array, you will get stale closures.
- Functional state updates (
setCount(c => c + 1)) are the best weapon against stale closures.
Done with this concept?
Mark it complete to track your progress. No login needed.