TL;DR / Closures capture variables by reference at creation time — if the closure persists across renders or async boundaries, it sees outdated values instead of current ones.
How It Works
Closure captures value at creation time
┌────────────┐ ┌────────────┐
│ Render #1 │ creates │ onClick │
│ count = 0 │───────────→│ captures │
└────────────┘ │ count = 0 │
└────────────┘
┌────────────┐
│ Render #2 │ onClick still sees
│ count = 1 │ count = 0 (STALE!)
└────────────┘
┌────────────┐
│ Render #3 │ onClick still sees
│ count = 2 │ count = 0 (STALE!)
└────────────┘
A closure is a function that remembers the variables from the scope where it was created. In React, every render creates a new scope with new local variables. When a function is created during render — an event handler, a callback passed to useEffect, a timer callback — it closes over the variables from that specific render.
If that function is used immediately (same render cycle), there is no problem. But if it persists — stored in a ref, passed to a setTimeout, used inside a useEffect cleanup, or memoized with useCallback — it continues to see the values from the render where it was born. Subsequent renders create new variables with updated values, but the old closure cannot see them.
The Classic Timer Example
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count); // Always logs 0
}, 1000);
return () => clearInterval(id);
}, []); // Empty deps — effect runs once, closure captures count=0
}
The interval callback captures count from the initial render. Even as the user clicks and count increments to 1, 2, 3, the callback still sees 0. It has a stale closure over the initial value.
Why This Happens in React
In a class component, this.state.count always reads the latest value because this is a mutable reference to the instance. But function components create new count variables each render. Hooks like useState return the current value for that render — they do not provide a persistent mutable binding.
This is by design: it makes renders pure snapshots. Each render's event handlers see the state as it was when the render occurred, which prevents a class of timing bugs. But it means long-lived callbacks must be handled carefully.
Solutions
Include the variable in useEffect dependencies. The most direct fix — if the effect depends on count, include it in deps. The effect re-runs on each change, creating a fresh closure.
useEffect(() => {
const id = setInterval(() => console.log(count), 1000);
return () => clearInterval(id);
}, [count]); // Recreates interval on each count change
Use a ref to hold the current value. Refs are mutable containers that persist across renders. By writing the latest value into a ref, closures can read the ref instead of the closed-over variable:
const countRef = useRef(count);
countRef.current = count; // Update on every render
useEffect(() => {
const id = setInterval(() => console.log(countRef.current), 1000);
return () => clearInterval(id);
}, []);
Use the functional form of setState. When you need the latest state to compute the next state, the updater function receives the current value directly, bypassing the closure:
setCount(prev => prev + 1); // Always uses current count
Use useEffectEvent (experimental). This proposed hook lets you read the latest values inside an effect without declaring them as dependencies. It is specifically designed to solve stale closures in effects that should not re-run when certain values change. As of React 19, it remains experimental and has not shipped in a stable release.
Stale Closures Beyond React
The problem is not React-specific. Any time you pass a callback to something that stores it — addEventListener, setTimeout, requestAnimationFrame, IntersectionObserver, or a third-party library — the callback closes over variables from its creation scope. If the surrounding scope has moved on, the callback is stale.
In vanilla JavaScript, this often manifests in event listeners that reference variables from an outer loop or a setup function that ran once. The fix is the same: either re-register the callback when values change, or use a mutable container (like a ref or a module-level variable) that the callback reads at execution time.
Gotchas
useCallbackwith stale deps is a stale closure factory.useCallback(() => doThing(count), [])memoizes a function that always sees the initialcount. This is the same bug, just wrapped in a memoization hook.console.logdebugging is misleading. If you log a stale value inside a closure, it looks correct at log time but is outdated by the time you read the console. Log from the render body to see current values.- React's ESLint plugin catches most cases. The
exhaustive-depsrule flags closures that reference state or props not listed in the dependency array. Do not disable it — it exists to prevent stale closures. - Refs solve staleness but introduce a different risk. Reading
ref.currentduring render (not in an effect or handler) can produce inconsistent values because the ref mutation is not tracked by React's rendering model. - Async/await creates closures at each
await. Variables captured before anawaitmay be stale by the time the promise resolves if the component has re-rendered in the interim.