AniUI Academy

Stale Closures and Common Bugs

The general shape of the stale-closure bug beyond useEffect's dependency array — setInterval capturing an old value, listeners that never see updated state, and the two reliable fixes.

9 min read

You've met one specific version of this bug already, in the dependency array lesson: an effect that reads a prop but omits it from the array, keeping a permanently outdated snapshot. This lesson generalizes it, because the same underlying mechanism — a stale closure — causes several of the most confusing bugs in real React code, not just that one.

The mechanism, in plain JavaScript

A closure is just a function that remembers the variables from the scope it was created in. React components re-render by calling the component function again, which means every render creates entirely new closures — new copies of any inner function, each one remembering that render's values of props and state. A stale closure bug happens whenever one of those old closures is kept around and called after a later render has already moved on to new values, and the old closure has no way of knowing that.

The classic interval example

function Counter() {
  const [count, setCount] = useState(0);
 
  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1); // reads `count` from THIS render, forever
    }, 1000);
    return () => clearInterval(id);
  }, []); // empty deps — this effect (and its closure) only runs once
 
  return <p>{count}</p>;
}

Because the dependency array is [], this effect runs exactly once, on the first render — where count is 0. The arrow function passed to setInterval closes over that specific 0, and since the effect never runs again, that closure is never recreated with a fresher count. Every second, it computes 0 + 1, sets state to 1... and the component re-renders with a new count of 1 — but the interval callback itself is still the old one from the first render, still computing 0 + 1. The display gets stuck at 1 forever, ticking uselessly in the background.

Fix one: functional updates

The cleanest fix, when the update only needs the previous value of the same state being changed, is the functional update form from the useState lesson:

useEffect(() => {
  const id = setInterval(() => {
    setCount((c) => c + 1); // reads the TRUE latest state when applied
  }, 1000);
  return () => clearInterval(id);
}, []);

This sidesteps the stale closure entirely — it no longer matters what count the closure captured, because React calls this function with whatever the actual current state is at the moment the update is applied, not with a value read from a stale variable.

Fix two: correct the dependency array

Functional updates only help because the new value is derived from the old value of the same state. When a closure needs some other value — a different prop, a different piece of state — the general fix is the dependency array itself: list what the effect actually reads, so its closures get rebuilt with current values whenever those values change.

function LiveSearch({ query }) {
  useEffect(() => {
    const id = setTimeout(() => {
      runSearch(query); // stale if query isn't a dependency
    }, 300);
    return () => clearTimeout(id);
  }, [query]); // correct — a fresh closure (and timeout) per query
}

Here, each time query changes, cleanup cancels the previous timeout (from the earlier closure, with the earlier query) and a new effect run creates a fresh closure that correctly captures the current query.

The same bug, outside useEffect

Because closures are a general JavaScript mechanism, not something specific to useEffect, the same failure shape can appear anywhere a function from an earlier render is retained and called later without being refreshed — inside a useCallback with an incomplete dependency array, for instance, or a ref holding onto an old callback. The lesson generalizes: whenever something behaves as though it's "not seeing the latest state," the first thing worth checking is whether a closure somewhere is holding onto an old render's snapshot instead of being recreated with the current one.

Try it yourself

The buggy version is shown commented out; the working version uses the functional update form. Watch the count actually keep incrementing, instead of freezing at 1:

Try it yourself
Loading playground...

What to remember

  • A stale closure happens when a function from an old render is retained and called after a later render has moved on, still referring to that old render's captured values.
  • The dependency-array version (an effect missing a value it reads) is one specific case of a more general JavaScript closure behavior.
  • The functional update form (setState(x => ...)) fixes the common case of a closure needing the latest value of the same state it's updating.
  • For anything else a closure reads, the fix is making sure the enclosing effect/callback's dependency array actually lists it, so the closure gets rebuilt with current values.

Check yourself

4 questions · pass 3/4 to unlock Error Boundaries

up to 50
  1. 1.In useEffect(() => { const id = setInterval(() => setCount(count + 1), 1000); return () => clearInterval(id); }, []), why does count only ever go from 0 to 1 and then stop increasing, even though the interval keeps firing?

  2. 2.What's one general fix for a stale closure that reads state to compute a new value based on the old one?

  3. 3.What's the other general fix, when a stale closure needs to read something other than the state being updated (e.g. a separate prop)?

  4. 4.Why can a stale closure also happen in a plain event handler passed as a prop, not just inside useEffect?

4 left to answer