· By John Kavanagh

Stale Closures in React: Why Handlers, Effects, and Timers Read Old State

Abstract image used to represent Stale Closures in React: Handlers, Effects, Timers
Image by Tami Mitchell.

In Brief

A stale closure happens when a callback remembers values from the render that created it, rather than the values currently on‑screen. Timers, event listeners, subscriptions, and async handlers can all outlive that render. The right fix depends on the flow: use functional state updates, correct dependencies, cancellation, or a ref bridge for long‑lived external callbacks.

Few React bugs feel more unfair than the one where the UI is showing the latest value, but a callback insists on using the old one.

You click a button, type into an input, or wait for an interval to fire, and the logic behaves as if the component is stuck in the past. This usually gets described as a stale closure problem, which can sound a bit abstract until you hit it a few times in real work.

The good news is that stale closures are not React being unpredictable. They are a side effect of two perfectly normal rules meeting each other:

  • JavaScript closures capture variables from a particular function call
  • every React render is a separate function call with its own props and state values

Once that mental model is solid, the fixes become much more straightforward.


Every Render Gets Its Own Snapshot

When a component renders, the values inside that render are fixed for anything created there.

If we render with count = 3, then an effect callback, timer callback, or event handler created during that render closes over 3. A later render with count = 4 creates a new callback that closes over 4, but the old callback does not magically update itself.

That is why this interval gets stuck:

const Counter = (): JSX.Element => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const intervalId = window.setInterval(() => {
      setCount(count + 1);
    }, 1000);

    return () => {
      window.clearInterval(intervalId);
    };
  }, []);

  return <p>{count}</p>;
};

The effect runs once, so the interval callback always sees the initial count value. It keeps asking React to set the count to 1.

The fix is not to "force React to notice". The fix is to write the update in a way that does not depend on the captured snapshot:

const Counter = (): JSX.Element => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const intervalId = window.setInterval(() => {
      setCount((currentCount) => currentCount + 1);
    }, 1000);

    return () => {
      window.clearInterval(intervalId);
    };
  }, []);

  return <p>{count}</p>;
};

The functional state update asks React for the latest value at update time, so the callback no longer depends on a stale closure.


Timers are the Obvious Case, but They are Not the Only Case

Any callback that outlives the render that created it can go stale.

That includes:

  • intervals and timeouts
  • DOM event listeners
  • subscription callbacks
  • promise continuations
  • async request handlers

Suppose a search component starts an async request:

const SearchBox = (): JSX.Element => {
  const [query, setQuery] = useState('');
  const [resultCount, setResultCount] = useState<number | null>(null);

  const handleSearch = async (): Promise<void> => {
    const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
    const data = (await response.json()) as { total: number };

    setResultCount(data.total);
  };

  return (
    <>
      <input
        aria-label="Search query"
        value={query}
        onChange={(event) => {
          setQuery(event.target.value);
        }}
      />
      <button onClick={handleSearch}>Search</button>
      <p>{resultCount ?? 'No search yet'}</p>
    </>
  );
};

This is not automatically wrong, but if the user starts multiple requests quickly, the closure around query can become part of a bigger bug. The real issue may be stale input, a race condition, or both.

That is worth keeping in mind: stale closures often travel with other timing problems. Fixing one does not always fix the whole flow.


Event Listeners Often Hide the Problem Better

Window listeners are a common place for stale closures because they get registered once and then live outside React's normal update cycle.

const EscapeLogger = ({
  value
}: {
  value: string;
}): null => {
  useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent): void => {
      if (event.key === 'Escape') {
        console.log(value);
      }
    };

    window.addEventListener('keydown', handleKeyDown);

    return () => {
      window.removeEventListener('keydown', handleKeyDown);
    };
  }, []);

  return null;
};

If value changes, pressing Escape still logs the original value because the registered listener came from the first render.

There are a few ways to handle this, depending on which values should recreate the subscription and which should simply be read when it fires.

If the listener should always use the latest value, include the dependency and re‑register the listener:

useEffect(() => {
  const handleKeyDown = (event: KeyboardEvent): void => {
    if (event.key === 'Escape') {
      console.log(value);
    }
  };

  window.addEventListener('keydown', handleKeyDown);

  return () => {
    window.removeEventListener('keydown', handleKeyDown);
  };
}, [value]);

If re‑registering is awkward because the external subscription should stay stable, a ref can hold the latest value instead.


Refs are Useful When the External Handle Must Stay Stable

Sometimes the problem is not the logic itself. It is that the external API expects one stable callback or one long‑lived subscription.

In those cases, a ref is often the simplest bridge:

const useLatest = <T,>(value: T): MutableRefObject<T> => {
  const valueRef = useRef(value);

  useEffect(() => {
    valueRef.current = value;
  }, [value]);

  return valueRef;
};

Now a listener can stay registered once but still read the latest value:

const EscapeLogger = ({
  value
}: {
  value: string;
}): null => {
  const latestValueRef = useLatest(value);

  useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent): void => {
      if (event.key === 'Escape') {
        console.log(latestValueRef.current);
      }
    };

    window.addEventListener('keydown', handleKeyDown);

    return () => {
      window.removeEventListener('keydown', handleKeyDown);
    };
  }, [latestValueRef]);

  return null;
};

This is a good fit for subscriptions, timers, and integration code. It is a poor fit when we are merely trying to dodge ordinary state flow. Refs are a tool for long‑lived external callbacks, not a loophole around React updates.


Effect Events in React 19.2 and Later

React 19.2 adds useEffectEvent() for logic called from Effects that needs the latest committed props and state without making that logic reactive. For the Escape listener above, an Effect can install its normal key handler and have that handler call an Effect Event which reads value. A change to value then does not require registering the listener again.

Declare the Hook at the top level of the component. Call the resulting Effect Event only from Effects or their callbacks in that component, and leave it out of the dependency array. Do not pass it to another component or use it as an ordinary button handler. This is not a way to hide a dependency that should restart the Effect: keep those reactive dependencies explicit.


Adding Dependencies is Not the Same as Understanding the Flow

It is tempting to treat the exhaustive‑deps warning as the whole story. It is helpful, but it is not the entire design review.

Sometimes adding a dependency is exactly right because the side effect should be recreated when that value changes.

Sometimes adding a dependency only hides a deeper problem:

  • a timer should have been a functional update
  • a request should have been cancelled or ignored when obsolete
  • a handler should have used an Effect Event, or a ref where appropriate, because the subscription itself should stay registered
  • some logic should not have lived in an effect at all

The point is not to win an argument with the dependency array. The point is to match the lifecycle of the callback to the lifecycle of the data it needs.


Stale Does Not Always Mean Wrong

This matters because closures are not a bug. They are just a snapshot.

Sometimes that is exactly what we want. A submit handler may intentionally use the values from the moment the user clicked submit. A timeout may intentionally close over the state that triggered the action. Not every old value is stale in the negative sense.

The bug starts when the callback is expected to follow the latest state, but the implementation is really following an older render snapshot.


A Simple Rule Helps in Most Cases

Ask one question:

"Should this callback use the latest value, or the value from the render that created it?"

If it should use the latest value:

  • use a functional state update where possible
  • include the right dependencies when the effect should be recreated
  • use an Effect Event for non‑reactive logic called from an Effect in React 19.2 or later; a ref remains useful for other external integration patterns

If it should use the original snapshot, the closure may already be correct.


Useful References

Stale closure bugs stop feeling mystical once we stop expecting callbacks to update themselves. They do not. They remember the render that created them. React then keeps rendering new snapshots beside them.

That is why the best fix is usually not a trick. It is a more honest statement of intent about which value the callback is supposed to care about.


Untangling a delivery problem?

Send the symptoms, constraints, and affected routes. I'll help identify whether the issue sits in the application, platform, content model, deployment path, or search surface.