The React stale closure bug: why your interval always logs the old state
setInterval logs 0 forever, event listeners see stale props, and callbacks fire with old values. Stale closures are React's most confusing bug class - here's how to spot and fix every variant.
You set up an interval in useEffect, it ticks every second, and it logs the same stale count forever. Or a WebSocket handler keeps referencing the user who was logged in when it subscribed. These are stale closures: a function captured variables from an old render and kept running long after that render was replaced.
The canonical broken interval
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // โ count is frozen at 0 forever
}, 1000);
return () => clearInterval(id);
}, []); // empty deps = closure over the FIRST renderThe effect ran once, on the first render, so the arrow function closed over count = 0. Every tick computes 0 + 1. The counter goes to 1 and stops. Nothing re-captures the variable because the effect never re-runs.
Fix 1: functional updates (best for state math)
setInterval(() => {
setCount((c) => c + 1); // โ
reads the LATEST value every tick
}, 1000);Fix 2: honest dependencies (best when the effect reads several values)
Add count to the dependency array and the effect tears down and re-creates the interval each change. That's correct and usually fine - clearing and re-setting an interval is cheap. The exhaustive-deps lint rule pushes you here for a reason: an effect that lists what it reads can never go stale.
Fix 3: a ref for long-lived subscriptions
const userRef = useRef(user);
useEffect(() => { userRef.current = user; }, [user]);
useEffect(() => {
socket.on('message', (msg) => {
handleMessage(msg, userRef.current); // โ
always current
});
return () => socket.off('message');
}, []); // subscribe once, read latest through the refWhen re-subscribing on every change is genuinely expensive (WebSockets, third-party SDK listeners), route the fresh value through a ref. Refs are a mutable box that survives renders, so the long-lived callback always dereferences the latest value. This is exactly the pattern React's experimental useEffectEvent formalizes.
How to recognize a stale closure
- A value 'stops updating' inside a timer, listener, or async callback - but renders correctly in JSX.
- The bug involves setInterval, setTimeout, socket handlers, or window event listeners registered in a mount-only effect.
- Logging inside the callback shows values from the past; logging in the component body shows the present.
- The exhaustive-deps rule is suppressed with an eslint-disable comment right above the crime scene.
Written by Appesto Engineering.