Stale Closures in React Hooks
What Is a Stale Closure?
If you've ever stared at a React bug where your state is "stuck" on an old value even though you know it updated, you've probably hit a stale closure. A closure captures variables from its enclosing scope at the time it is created. In React, every render creates a new scope. A "stale closure" is a function that captured variables from a previous render and still references those outdated values.
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count); // Always logs the count from the render when this effect ran
}, 1000);
return () => clearInterval(id);
}, []); // Empty deps — effect runs once, captures count = 0 forever
return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>;
}
Try this yourself. Click the button 5 times. The screen shows Count: 5. But the interval logs 0 every second. The interval callback is a closure that captured count from the first render, and it never gets a fresh value. Maddening, right?
Each React render is like a snapshot — a photograph of all state and props at that moment. Functions created during that render are holding that photograph. If the function lives beyond its render (in a timer, event listener, or async callback), it is looking at an old photograph while the world has moved on. The function does not know the world changed — it only sees what it captured.
How Closures Work Per Render
React function components run from top to bottom on every render. Each run creates a new closure scope with fresh values.
function Counter() {
const [count, setCount] = useState(0);
// Render 1: count = 0
// Render 2: count = 1
// Render 3: count = 2
function handleClick() {
// This function captures 'count' from THIS render's scope
console.log(count);
}
// handleClick from render 1 closes over count=0
// handleClick from render 2 closes over count=1
// handleClick from render 3 closes over count=2
}
This is fine for event handlers in JSX — when the user clicks the button, they trigger the handler from the latest render. But it becomes a problem when a function outlives its render.
Common Stale Closure Patterns
Let's walk through the patterns that bite developers in production, starting with the most common.
Pattern 1: setInterval in useEffect
The classic stale closure bug — you'll see this in almost every React codebase at some point:
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setSeconds(seconds + 1); // BUG: seconds is always 0
}, 1000);
return () => clearInterval(id);
}, []);
return <p>{seconds} seconds</p>;
}
// Display: "1 seconds" — never increments past 1
// setSeconds(0 + 1) runs every second — always setting to 1
The interval callback captures seconds = 0 from mount. Every tick calls setSeconds(0 + 1) — always 1.
Fix: Use the functional updater form:
useEffect(() => {
const id = setInterval(() => {
setSeconds(prev => prev + 1); // No closure dependency on seconds
}, 1000);
return () => clearInterval(id);
}, []);
The functional updater prev => prev + 1 receives the current state as an argument. It does not close over seconds at all — the stale closure problem disappears.
Pattern 2: Event Listeners
function ScrollTracker() {
const [scrollY, setScrollY] = useState(0);
const [isTracking, setIsTracking] = useState(true);
useEffect(() => {
function handleScroll() {
if (isTracking) { // BUG: isTracking is always true (captured at mount)
setScrollY(window.scrollY);
}
}
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []); // Missing isTracking in deps
}
Even after isTracking is set to false, the scroll handler still runs because it captured isTracking = true.
Fix: Include isTracking in the dependency array:
useEffect(() => {
function handleScroll() {
if (isTracking) {
setScrollY(window.scrollY);
}
}
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [isTracking]); // Re-subscribe when isTracking changes
Now when isTracking changes, the effect cleans up the old listener and creates a new one with the fresh value.
Pattern 3: Async Callbacks
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const handleSearch = () => {
// query is captured at the time handleSearch was created
setTimeout(() => {
fetchResults(query).then(setResults);
// If the user changed the query before the timeout fires,
// this fetches the OLD query
}, 2000);
};
return <button onClick={handleSearch}>Search "{query}"</button>;
}
The Dependency Array as the Fix
Here's the thing most people get wrong about dependency arrays. They don't just control "when should this run" — they control "which values does this closure need to stay fresh." When a dependency changes, React:
- Runs the cleanup function from the previous effect instance
- Runs the effect function again, creating a new closure with current values
The react-hooks/exhaustive-deps ESLint rule enforces this. It knows which variables are used inside the closure and ensures they are all listed in the dependency array. Ignoring this rule is the primary source of stale closure bugs.
// ESLint warns: 'count' is missing from the dependency array
useEffect(() => {
document.title = `Count: ${count}`;
}, []); // ← count is used but not listed
// Fixed:
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]); // Effect re-runs when count changes
Suppressing the ESLint rule with // eslint-disable-next-line is almost always wrong. If you think you need to omit a dependency, you are usually reaching for the wrong pattern. Use a ref, a functional updater, or restructure the code instead of lying about what the closure captures.
useRef as an Escape Hatch
Sometimes you need a value that is always current without re-running effects. This is where refs become your best friend. Refs are mutable containers that persist across renders — reading ref.current always returns the latest value, not a closed-over snapshot.
function useLatest(value) {
const ref = useRef(value);
ref.current = value; // Sync on every render
return ref;
}
function Timer() {
const [count, setCount] = useState(0);
const countRef = useLatest(count);
useEffect(() => {
const id = setInterval(() => {
console.log(countRef.current); // Always the latest count
}, 1000);
return () => clearInterval(id);
}, []); // No deps needed — ref.current is always fresh
return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>;
}
The effect runs once and never re-runs. But countRef.current is updated on every render (synchronously, before effects run), so the interval always reads the latest value.
The "Ref Pattern" for Callbacks
A common pattern in libraries and production code: keep a ref synced to the latest version of a callback, so that external subscribers (event listeners, intervals, third-party libraries) always invoke the current version.
function useEventCallback(callback) {
const callbackRef = useRef(callback);
// Update ref on every render (synchronously)
useLayoutEffect(() => {
callbackRef.current = callback;
});
// Return a stable function that delegates to the ref
return useCallback((...args) => {
return callbackRef.current(...args);
}, []);
}
Usage:
function ChatRoom({ roomId, onMessage }) {
const stableOnMessage = useEventCallback(onMessage);
useEffect(() => {
const connection = connect(roomId);
connection.on('message', stableOnMessage);
// stableOnMessage never changes — effect doesn't re-run when onMessage changes
// But stableOnMessage always calls the LATEST onMessage via the ref
return () => connection.disconnect();
}, [roomId, stableOnMessage]);
}
Why useLayoutEffect for the ref update?
Using useLayoutEffect (instead of plain assignment callbackRef.current = callback) ensures the ref is updated before any useEffect reads it during the same commit. If you assign during render (callbackRef.current = callback at the top of the component), it works in most cases but can cause issues in concurrent mode — React may render multiple times before committing, and each render would update the ref even for abandoned renders. useLayoutEffect runs synchronously after the commit, guaranteeing the ref reflects the committed state.
In practice, the simple assignment works for most applications. The useLayoutEffect approach is more correct in concurrent mode scenarios.
Stale Closures and Concurrent Mode
Just when you thought you had a handle on this, concurrent mode adds another layer. React may start rendering a component, pause, and resume later. During the pause, state may change. If the render-phase code captured values via closure, those values may be stale when rendering resumes — but React handles this correctly internally by re-executing the component from scratch on restart.
The user-facing concern: any function that "escapes" the render (passed to setTimeout, setInterval, event listeners, or stored in a ref) can capture values from an abandoned render. The patterns above (refs, functional updaters, exhaustive deps) remain the correct solutions.
function ConcurrentSafe({ data }) {
const dataRef = useRef(data);
dataRef.current = data;
useEffect(() => {
// Using ref ensures we always read committed data,
// not data from a potentially abandoned render
saveToServer(dataRef.current);
}, [data]);
}
Fixing the Race Condition
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let cancelled = false; // Cleanup flag
async function fetchUser() {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
if (!cancelled) { // Only update if this effect instance is still current
setUser(data);
}
}
fetchUser();
return () => {
cancelled = true; // Mark this effect instance as superseded
};
}, [userId]);
return user ? <div>{user.name}</div> : <p>Loading...</p>;
}
The cleanup function runs before the next effect instance. Setting cancelled = true ensures that the old fetch's .then handler (which is a stale closure) does not write stale data.
Summary of Fix Patterns
| Problem | Fix | When to Use |
|---|---|---|
| Timer reads stale state | Functional updater setState(prev => ...) | When you only need previous state to compute next state |
| Effect needs current value without re-running | useRef + sync on render | When re-running the effect is expensive (WebSocket, subscription) |
| Callback passed to child needs to be stable but current | Ref pattern (useEventCallback) | When a callback is used in effects that should not re-run |
| Async operation overwrites newer result | Cleanup flag or AbortController | Any effect that triggers async work based on changing deps |
| Event listener reads stale state | Include dependency in array | When re-subscribing is cheap |
- 1Every render creates a new closure scope. Functions created in that render capture that render's values — permanently.
- 2Stale closures occur when a function outlives its render: timers, listeners, async callbacks, subscriptions.
- 3The dependency array controls when closures are refreshed. Lying about dependencies (omitting values) causes stale closures.
- 4Functional updaters (setState(prev => ...)) avoid closure over state — use them when computing next state from previous.
- 5useRef holds a mutable box that persists across renders. Reading ref.current always gives the latest value.
- 6The ref pattern (sync ref to latest callback, return stable wrapper) gives you both stability and freshness.
- 7Async race conditions are a stale closure variant. Use cleanup flags or AbortController to prevent stale writes.
Q: Explain what a stale closure is in React hooks and give three patterns to fix it.
A strong answer covers: closures capture values from their creation scope, React creates new scopes per render so functions from old renders hold old values. Three fixes: (1) functional updater form setState(prev => ...) removes the need to close over state, (2) useRef to hold a mutable container that always reflects the latest value, (3) exhaustive dependency arrays to ensure effects re-run and create fresh closures when captured values change. Bonus: the ref callback pattern for stable-but-current callbacks, and cleanup flags for async race conditions.