Back to the blog
Reactยท Aug 5, 2026ยท 8 min read

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.

#react#closures#hooks#useref

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 render

The 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 ref

When 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.