Fixing Stale Closures in useEffect That Silently Read Outdated State
An event handler or interval set up inside useEffect keeps acting on the value state had when the effect first ran, even after the component has re-rendered with new state many times over. Nothing throws an error — the UI just quietly does the wrong thing, which makes this one of the harder React bugs to diagnose from the symptom alone.
The Problem
A component sets up an event listener, a setInterval, or a WebSocket message handler inside a useEffect, and that handler reads a piece of component state. Everything works on the first render, but after the state updates — a counter increments, a toggle flips, a form field changes — the handler keeps acting as if the state were still at its original value. Clicking a button logs the count from several renders ago; a save handler submits data from before the user's last edit. There's no thrown error, no console warning by default, and the component re-renders correctly on screen — only the closure captured inside the effect is out of date.
Why It Happens
An effect's callback closes over the props and state values from the render it was created in
Every render of a function component creates new versions of its variables, and any function defined during that render — including the callback passed to useEffect — closes over those specific values. If the effect's dependency array doesn't include the state the callback reads, React has no reason to re-run the effect and replace the closure, so the original callback (and its stale captured values) keeps running indefinitely.
An empty dependency array is often used to intentionally run setup once — which locks in stale values by design
useEffect(() => { ... }, []) is a common, valid pattern for "run this only on mount" — a WebSocket connection, a single event listener. But if the callback inside also reads state, an empty array doesn't just skip re-running the effect for performance; it guarantees the callback never sees any state update that happens after mount.
Long-lived subscriptions (intervals, WebSockets, DOM listeners) are the most common place this surfaces
A one-shot effect that reads state once and finishes immediately can't go stale in a way that matters. The bug shows up specifically in effects that set up something long-lived — setInterval, addEventListener, a socket's onmessage — where the same closure keeps firing across many renders, each time still holding whatever state existed when the effect last ran.
The dependency array warning is easy to silence without understanding why it's there
ESLint's react-hooks/exhaustive-deps rule flags exactly this class of bug by warning when a value used inside an effect is missing from its dependency array. Disabling the rule with a comment, or adding a dependency that doesn't actually cause a meaningful re-run, removes the warning without removing the underlying staleness.
The Fix
1. Include every value the effect's callback reads in its dependency array
useEffect(() => {
const id = setInterval(() => {
console.log(count); // reads count
}, 1000);
return () => clearInterval(id);
}, [count]); // re-creates the interval with the current count on every change
Letting the effect re-run whenever count changes ensures the closure inside it is always the one from the most recent render, not a stale one from mount. This is the correct default fix whenever re-running the setup (clearing and recreating the interval, listener, or subscription) is cheap enough to do on every relevant change.
2. Use a functional state update to avoid needing the value in the closure at all
useEffect(() => {
const id = setInterval(() => {
setCount((prevCount) => prevCount + 1); // reads the current state directly, not a closed-over value
}, 1000);
return () => clearInterval(id);
}, []); // safe to keep empty — the callback no longer reads count from its closure
When the effect only needs to update state based on its previous value, the functional updater form of setState reads the current state at call time rather than through the closure, which removes the staleness problem without needing the effect to re-run at all.
3. Keep the latest value in a ref when the effect genuinely must stay mounted once
const latestCount = useRef(count);
useEffect(() => {
latestCount.current = count;
}, [count]);
useEffect(() => {
const id = setInterval(() => {
console.log(latestCount.current); // always the latest value, read imperatively
}, 1000);
return () => clearInterval(id);
}, []); // the long-lived effect itself never needs to re-run
For a genuinely expensive-to-recreate subscription (a WebSocket connection, a native event listener that shouldn't be torn down and rebuilt on every state change), a ref that's kept in sync with the latest state via a second, cheap effect gives the long-lived callback a way to read current data imperatively without re-running the expensive setup.
4. Keep react-hooks/exhaustive-deps enabled and fix the cause instead of the warning
// .eslintrc
{
"rules": {
"react-hooks/exhaustive-deps": "error"
}
}
The rule exists specifically to catch this bug class before it ships. Treating a missing-dependency warning as something to silence, rather than as a signal to apply one of the fixes above, is how stale closures make it into production in the first place.
Why This Works
Each fix removes the mismatch between what the effect's closure captured and what the component's state actually is, through a different mechanism. Adding the real dependency makes React re-create the closure whenever the value it depends on changes; the functional updater sidesteps the closure entirely for the common case of "update based on previous value"; a ref gives a long-lived callback a way to read current data without needing the whole subscription to be torn down and rebuilt; and keeping the lint rule enabled ensures the next instance of this bug is caught at write time instead of surfacing as a confusing runtime symptom later.
Conclusion
Stale closures in useEffect aren't a logic bug in the traditional sense — the code inside the effect is doing exactly what it captured at creation time; the state has simply moved on since then. Include every value the effect actually reads in its dependency array, use the functional setState updater when the effect only needs the previous value, fall back to a ref for genuinely expensive long-lived subscriptions, and keep react-hooks/exhaustive-deps enabled so the next stale closure gets caught before it ships.
