The Stale Closure Problem in React Hooks
Closures are fundamental to JavaScript: an inner function retains access to the variables in its enclosing scope. In React hooks, every render creates new closures — new versions of your functions that capture the current values of state and props. This is normally what you want.
The stale closure problem occurs when a function that was created in an earlier render is called later — and it still holds the variable values from when it was created, not the current values.
A Classic Example
function Counter() {
const [count, setCount] = useState(0)
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1) // ← 'count' captured at effect creation
}, 1000)
return () => clearInterval(id)
}, []) // empty deps — effect runs once, captures count = 0
return <p>Count: {count}</p>
}
The interval fires every second and calls setCount(count + 1). But count is 0 — it was captured when the effect was created (on mount, when count was 0). Every second, setCount(0 + 1) is called. The counter never goes above 1.
The interval callback is a stale closure: it closed over the initial value of count and never updated.
Why It Happens
Each render of a React component runs the function body again from top to bottom. Each invocation creates new closure scope. count in render 3 is a different binding from count in render 1.
When a callback from render 1 is called during render 5, it still references render 1's count. React's state has updated, but the function cannot see the new value — it is locked to its creation context.
Fix 1: Include in Dependency Array
The correct fix for useEffect: add count to the dependency array. The effect re-runs on every count change, creating a new closure with the current value:
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1) // now 'count' is always current
}, 1000)
return () => clearInterval(id)
}, [count]) // re-run when count changes
But now the interval resets on every count change. For this specific case, Fix 2 is better.
Fix 2: Functional State Updates
When you only need the previous value to compute the next value, the functional form of setState is the cleanest solution — it does not capture the stale value at all:
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1) // 'c' is always the current state
}, 1000)
return () => clearInterval(id)
}, []) // empty deps, no stale closure
The updater function c => c + 1 receives the current state value from React, not from the closure. This pattern is safe even with an empty dependency array.
Fix 3: useRef for Latest Value
When you need the current value inside a callback that should NOT re-initialize when the value changes, a useRef acts as a stable container for the latest value:
function useLatest(value) {
const ref = useRef(value)
useEffect(() => {
ref.current = value
})
return ref
}
function Component({ onAction }) {
const onActionRef = useLatest(onAction)
useEffect(() => {
const id = setInterval(() => {
onActionRef.current() // always calls the latest onAction
}, 1000)
return () => clearInterval(id)
}, [onActionRef]) // ref is stable — effect runs once
}
The ref's .current is updated via a separate effect on every render, but the interval callback always reads the latest value through the ref rather than closing over the prop directly.
Fix 4: useCallback with Correct Dependencies
For callbacks passed to child components, include all referenced state/props in the dependency array:
// ❌ stale — doSomething may use outdated values
const handleClick = useCallback(() => {
doSomething(count, user)
}, []) // count and user are missing
// ✅ fresh — callback recreated when count or user changes
const handleClick = useCallback(() => {
doSomething(count, user)
}, [count, user])
The trade-off: the callback reference changes when count or user changes, which can trigger re-renders of React.memo children. This is the correct trade-off — correctness before micro-optimization.
ESLint: Your Stale Closure Detector
The eslint-plugin-react-hooks rule react-hooks/exhaustive-deps will warn on missing dependencies in useEffect, useMemo, and useCallback dependency arrays. It is one of the highest-ROI ESLint rules for React codebases:
npm install --save-dev eslint-plugin-react-hooks
{
"rules": {
"react-hooks/exhaustive-deps": "warn"
}
}
Every warning is a potential stale closure or an unnecessary re-run. Take them seriously rather than suppressing them with // eslint-disable-line.
The Deeper Principle
Stale closures are not a React bug — they are a consequence of how JavaScript closures work combined with React's per-render execution model. Every render is a snapshot. Functions created in a render capture that snapshot. When a function outlives the render that created it (via intervals, timeouts, event listeners, or subscriptions), it holds a frozen view of the world.
The solutions all follow the same principle: either give the function the current value when it is called (functional setState, refs) or ensure the function is recreated with current values whenever its dependencies change (dependency arrays).
Conclusion
The stale closure problem is one of the most important React concepts to internalize. It explains confusing bug patterns where state updates seem to be ignored, where event handlers use old data, and where timers always compute the same value. The fix depends on the case: functional setState for state-based computations, useRef for values that should not trigger effect re-runs, and correct dependency arrays for everything else. The exhaustive-deps ESLint rule catches most cases before they reach production.
