Stale Closures: When Your useEffect Is Stuck in the Past
Picture this: a live dashboard that polls for new notifications every 3 seconds. The count increments in state, the UI updates, everything looks right — except the interval callback is silently reading count = 0 on every tick, because it…
Stale Closures: When Your useEffect Is Stuck in the Past
Picture this: a live dashboard that polls for new notifications every 3 seconds. The count increments in state, the UI updates, everything looks right — except the interval callback is silently reading count = 0 on every tick, because it closed over the value from the first render and never let go. The bug ships. Your senior reviewer spots it in 10 seconds during the post-incident review. Nobody caught it during the PR.
Stale closures are one of the subtler things senior devs flag in React PRs. They don't throw. TypeScript doesn't warn about them. They just quietly operate on outdated data until something downstream breaks in a confusing way.
The Mistake
The basic form:
// components/LiveCounter.tsx
function LiveCounter() {
const [count, setCount] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
console.log(`Current count: ${count}`); // always logs 0
setCount(count + 1); // always sets to 1
}, 1000);
return () => clearInterval(interval);
}, []); // empty deps — captures count from first render only
return <div>{count}</div>;
}This component displays 1 in the UI forever. The interval callback captured count = 0 on mount. Every tick reads 0, sets 0 + 1 = 1. The counter never moves past 1.
Why This Happens
Every render in React is a snapshot. When a render runs, all functions defined inside it — effect callbacks, event handlers, memoized functions — close over the values that existed at that specific render. That's JavaScript closures working exactly as designed.
The problem is timing. When you register something long-lived — an interval, a subscription, an event listener — it captures values from render N. But it runs during renders N+1, N+2, N+100. The world has moved on. The closure hasn't.
Render 1: count = 0 → interval captures count = 0
Render 2: count = 1 → (interval still sees count = 0)
Render 3: count = 2 → (interval still sees count = 0)
Empty or incomplete dependency arrays are the trigger. The effect runs once, registers a callback that closes over stale values, and that callback runs indefinitely with a frozen worldview.
Real-World Examples
Example 1: Polling that erases its own history
A dashboard polls an API and accumulates results:
// Before: stale closure on items
function NotificationFeed() {
const [items, setItems] = useState<Notification[]>([]);
useEffect(() => {
const id = setInterval(async () => {
const newItems = await fetchNotifications();
setItems([...items, ...newItems]); // items is always [] in here
}, 3000);
return () => clearInterval(id);
}, []); // items captured once as empty array, never updated
return <ul>{items.map(n => <li key={n.id}>{n.message}</li>)}</ul>;
}items in the callback is frozen at []. Every poll replaces the list with only the newest batch — everything before it gets wiped. Users wonder why their notification history keeps disappearing.
// After: functional update bypasses the closure entirely
function NotificationFeed() {
const [items, setItems] = useState<Notification[]>([]);
useEffect(() => {
const id = setInterval(async () => {
const newItems = await fetchNotifications();
setItems(prev => [...prev, ...newItems]); // always has the latest state
}, 3000);
return () => clearInterval(id);
}, []);
return <ul>{items.map(n => <li key={n.id}>{n.message}</li>)}</ul>;
}The functional form of setItems receives the current state directly from React — no closure involved. This is the go-to fix whenever you need the previous state inside an interval or subscription.
Example 2: WebSocket handler reading stale props
This one is sneaky because the staleness is in props, not state:
// Before: message handler captures userId from initial render
function ChatRoom({ userId }: { userId: string }) {
const [messages, setMessages] = useState<Message[]>([]);
useEffect(() => {
const socket = new WebSocket('wss://chat.example.com');
socket.onmessage = (event) => {
const msg: Message = JSON.parse(event.data);
// userId is stale — if the user navigated to a new chat room,
// this still filters on the original userId
if (msg.recipientId === userId) {
setMessages(prev => [...prev, msg]);
}
};
return () => socket.close();
}, []); // userId not in deps
return <MessageList messages={messages} />;
}If userId changes — the user switches accounts, or navigation passes a different ID — the WebSocket handler still filters on the original value. Messages to the new user get dropped. Messages intended for the old session might appear in the new one.
// After: include userId in deps so the effect re-runs on change
useEffect(() => {
const socket = new WebSocket('wss://chat.example.com');
socket.onmessage = (event) => {
const msg: Message = JSON.parse(event.data);
if (msg.recipientId === userId) {
setMessages(prev => [...prev, msg]);
}
};
return () => socket.close();
}, [userId]); // cleanup tears down old socket, new one opens for new userIdThe cleanup function closes the old socket; the new effect registers a fresh handler for the new userId.
Resist the urge to add setMessages([]) to that cleanup to clear the old room's messages. On unmount it's a no-op — React 19 discards state updates for unmounted components silently — and on a userId change it's redundant, because the effect that runs next can clear the list itself. If you want messages reset when the room changes, the cleaner move is to remount the whole component with <ChatRoom key={userId} /> and let React throw the old state away for you.
Example 3: Autosave that always saves empty content
A text editor that debounces saves on every keystroke:
// Before: keyup handler captures stale `content`
function TextEditor() {
const [content, setContent] = useState('');
useEffect(() => {
let saveTimer: ReturnType<typeof setTimeout>;
const handleKeyUp = () => {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
// content is always '' — captured when the effect first ran
saveDraft(content);
}, 1000);
};
document.addEventListener('keyup', handleKeyUp);
return () => {
document.removeEventListener('keyup', handleKeyUp);
clearTimeout(saveTimer);
};
}, []); // content not in deps
return (
<textarea
value={content}
onChange={e => setContent(e.target.value)}
/>
);
}Every keystroke triggers the debounce, and every debounced save sends an empty string to the server. The user types a 500-word draft. Everything saved is "".
The fix here is a ref — the right tool when you need to read the current value of something from inside a long-lived callback without re-registering the listener:
// After: ref gives the handler access to the live value
function TextEditor() {
const [content, setContent] = useState('');
const contentRef = useRef(content);
// Keep the ref in sync on every render
useEffect(() => {
contentRef.current = content;
});
useEffect(() => {
let saveTimer: ReturnType<typeof setTimeout>;
const handleKeyUp = () => {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
saveDraft(contentRef.current); // always current
}, 1000);
};
document.addEventListener('keyup', handleKeyUp);
return () => {
document.removeEventListener('keyup', handleKeyUp);
clearTimeout(saveTimer);
};
}, []); // effect never needs to re-run — reads through the ref
return (
<textarea
value={content}
onChange={e => setContent(e.target.value)}
/>
);
}ref.current evaluates to the actual current value at the time it's read. The closure captures the ref object, which never changes identity — so there's a stable box in the closure and a fresh value inside it.
If you're on React 19.2 or later, there's now a purpose-built version of this pattern. useEffectEvent gives you a function that always sees the latest render's values, and which you deliberately leave out of the dependency array:
import { useEffectEvent, useEffect, useState } from 'react';
function TextEditor() {
const [content, setContent] = useState('');
// Reads whatever `content` is at the moment it's called, not at registration
const persist = useEffectEvent(() => saveDraft(content));
useEffect(() => {
let saveTimer: ReturnType<typeof setTimeout>;
const handleKeyUp = () => {
clearTimeout(saveTimer);
saveTimer = setTimeout(persist, 1000);
};
document.addEventListener('keyup', handleKeyUp);
return () => {
document.removeEventListener('keyup', handleKeyUp);
clearTimeout(saveTimer);
};
}, []); // persist is intentionally omitted — see below
return <textarea value={content} onChange={e => setContent(e.target.value)} />;
}Same outcome as the ref, less plumbing, and it states the intent: this callback is an event, not a reactive dependency.
Worth knowing exactly what you're promised here, because it's easy to assume too much. The function's identity is not stable between renders, and React did that on purpose — eslint-plugin-react-hooks errors if you put it in a dependency array, and the unstable identity is what makes that mistake visible instead of silent. You leave it out of the deps because it's not reactive, not because it never changes. The other rules: call it only from inside Effects or other Effect Events, never during render, and don't pass it down to another component or hook. And don't reach for it as a general-purpose way to shorten a dependency array — that's how you hide the bug this article is about rather than fix it. The manual ref is still what you want on anything older than 19.2.
Example 4: useCallback with missing dependencies
Memoized callbacks are a particularly common hiding spot for stale closures, because the whole point of useCallback is to keep the function reference stable:
// Before: handleSearch captures filters from first render
function SearchPage() {
const [filters, setFilters] = useState({ category: 'all', sort: 'recent' });
const [results, setResults] = useState<Result[]>([]);
const handleSearch = useCallback(async (query: string) => {
const data = await search(query, filters); // stale filters
setResults(data);
}, []); // filters missing from deps
return (
<>
<FilterPanel onChange={setFilters} />
<SearchBox onSearch={handleSearch} />
</>
);
}handleSearch is memoized with an empty dependency array. It captures filters from the first render. When the user changes the category and searches, handleSearch fires with the original { category: 'all', sort: 'recent' }. The filter panel updates visually; the results ignore it.
// After: include filters in the dependency array
const handleSearch = useCallback(async (query: string) => {
const data = await search(query, filters);
setResults(data);
}, [filters]); // re-memoizes when filters changeYes, this creates a new function reference when filters changes. That's correct — the memoized function should be invalidated when its inputs change.
The Fix
Three patterns cover most cases:
Functional state updates — when you need the previous state inside an interval or subscription. setCount(c => c + 1) gets current state from React directly, no closure involved. Use this whenever you'd otherwise write setState(stateVar + something) inside a long-lived callback.
useRef for live values — when you need to read the current value of state or props from inside a callback you can't easily re-register. Assign the latest value to ref.current in a bare useEffect (no deps), then read ref.current inside the long-lived callback.
Correct dependency arrays — everything the effect reads should be in the deps array. If adding a dep makes the effect run too often, that's usually a sign the effect needs restructuring, not that the dep should be omitted.
And the highest-ROI tool: turn on react-hooks/exhaustive-deps:
{
"rules": {
"react-hooks/exhaustive-deps": "error"
}
}Every warning this rule produces is a potential stale closure. Don't suppress warnings with // eslint-disable-next-line. If adding the dep breaks the effect, fix the effect.
How to Catch This in Code Review
Things worth flagging:
useEffect(() => { ... }, [])that reads state or props inside. An empty dep array combined with any state or prop reference is a yellow flag. Ask: "Is there anything in here that could change after mount? What happens to this callback when it does?"setCount(count + 1)insidesetIntervalorsetTimeout. Should almost always be the functional formsetCount(c => c + 1).useCallbackoruseMemowith[]that reference state or props. Same issue, different hook.- Event listeners registered in
useEffectthat reference state. The handler captures the state snapshot from when the effect ran — if the effect only runs once, that snapshot is from mount. // eslint-disable-next-line react-hooks/exhaustive-depswithout explanation. Treat these like a// @ts-ignore: they need a comment explaining exactly what breaks when the dep is included, because if the dep is genuinely needed, the disable comment is hiding a bug.
Comments (0)
No comments yet. Be the first to share your thoughts!