Skip to content
Guides3 min readUpdated

Stale closures, and how to stop them

The short answer

A stale closure happens when a function captures state from the render in which it was created and keeps using that snapshot after the state has changed. It shows up most often in setInterval, setTimeout and event listeners registered once with an empty dependency array. The three fixes are: use the functional updater form of setState, add the value to the dependency array so the effect re-subscribes, or store the callback in a ref and always invoke the latest version.

It is the bug that makes people distrust hooks. The code looks right, React reports no error, and the number on screen simply refuses to move.

The classic reproduction

Counts to 1, then stops
1function BrokenCounter() {
2  const [count, setCount] = useState(0);
3
4  useEffect(() => {
5    const id = setInterval(() => {
6      setCount(count + 1);   // `count` is 0 in this closure — forever
7    }, 1000);
8    return () => clearInterval(id);
9  }, []);                    // runs once, captures the first render
10
11  return <p>{count}</p>;
12}

The effect runs once, on mount. At that moment count is 0, and the arrow function passed to setInterval closes over that binding. Every tick computes 0 + 1. The state does update to 1, which re-renders — but the interval is still holding the function from the very first render.

Key takeaway

Each render creates a new set of variables. A function created during a render sees that render's values permanently. Nothing "updates" a closure after the fact.

Fix 1: the functional updater

When the new state derives from the old, pass a function to the setter. React hands it the current value, so the closure never needs to know it.

1useEffect(() => {
2  const id = setInterval(() => {
3    setCount((current) => current + 1);   // ✓ always the latest
4  }, 1000);
5  return () => clearInterval(id);
6}, []);
This is the best fix when it applies. It also removes count from the dependency array legitimately, so the interval is created once instead of being torn down and rebuilt every second.

Fix 2: declare the dependency honestly

If the effect really does need the value — not just to update it — put it in the dependency array and let the effect re-run.

1useEffect(() => {
2  const id = setInterval(() => {
3    console.log('current query:', query);
4  }, 1000);
5  return () => clearInterval(id);
6}, [query]);   // ✓ new closure whenever query changes
This tears down and recreates the interval on every change. For a logging effect that is fine; for an interval that must keep a steady cadence, or a WebSocket you do not want to reconnect, it is not — use fix 3.

Fix 3: the latest-ref pattern

A ref is a mutable box shared by every render. Write the newest callback into it on each render, and have the long-lived subscription read from the box at call time.

Fresh values, stable subscription
1function useInterval(callback: () => void, delay: number | null) {
2  const saved = useRef(callback);
3
4  useEffect(() => {
5    saved.current = callback;      // every render refreshes the box
6  }, [callback]);
7
8  useEffect(() => {
9    if (delay === null) return;
10    const id = setInterval(() => saved.current(), delay);
11    return () => clearInterval(id);
12  }, [delay]);                     // the interval itself is untouched
13}

The interval is created once and never restarted, yet each tick calls the newest callback with the newest state. This is what useInterval, useTimeout, useEventListener and useEvent in this library all do internally.

Using the hook version
1import { useInterval } from '@danixsoft/hooks';
2
3function Counter() {
4  const [count, setCount] = useState(0);
5
6  // No dependency array to get wrong, no cleanup to forget.
7  useInterval(() => setCount(count + 1), 1000);
8
9  return <p>{count}</p>;   // ✓ counts up correctly
10}

Where else it bites

SituationSymptomFix
setInterval / setTimeout in an effectValue frozen at its initial stateUpdater form, or latest-ref
addEventListener with [] depsHandler reads old propsLatest-ref (useEventListener)
useCallback with missing depsMemoised function sends stale dataAdd the dep, or use useEvent
A promise .then after an awaitWrites state the user already changedGuard with useIsMounted
WebSocket / subscription callbacksMessages handled against stale stateLatest-ref

Catching it before it ships

  • Enable react-hooks/exhaustive-deps and treat it as an error, not a warning. It catches the overwhelming majority of these.
  • When you deliberately omit a dependency, leave a comment explaining why — an unexplained disable is where the next bug hides.
  • Prefer the functional updater form for any state derived from previous state, as a habit rather than a fix.
  • Reach for a hook that already solves it. useInterval, useTimeout, useEventListener and useEvent exist precisely so this pattern is written once.
Is a stale closure a React bug?

No, it is standard JavaScript closure behaviour. A function captures the variables in scope when it is created. React renders create a new scope each time, so a function that outlives its render keeps looking at the old one.

Why does adding the value to the dependency array work?

Because the effect then re-runs whenever the value changes, creating a new closure over the new value — and cleaning up the old subscription first. The trade-off is that the subscription is torn down and rebuilt each time.

When should I use a ref instead of a dependency?

When the subscription is expensive or must not be interrupted — an interval that needs a steady cadence, a WebSocket, an IntersectionObserver — but the callback still needs current state. That is exactly what the latest-ref pattern is for.

Does useEvent solve this everywhere?

It solves it for callbacks: you get a stable function identity that always reads current state. It does not help with values you read directly inside an effect body — for those you still need the dependency array or the updater form.

Keep reading