ML
React

Stale Closures: Why Your State Is "Old"

Every React render captures its own props, state, and handlers in a closure — and that snapshot is exactly why your setInterval, event listener, or effect reads "old" values.

May 24, 202610 min readReactHooks

A stale closure is not a React bug — it is JavaScript closures behaving correctly in a model where your function component runs again on every render. Each render produces a fresh scope, and any callback created there captures the props and state as they were at that render. Stash such a callback somewhere that outlives the render — a setInterval, an event listener, a subscription — and it keeps reading the snapshot it was born with.

1. Every render is its own closure

The mental model that makes all of this obvious: a function component runs top to bottom on every render. The count you destructure from useState is not a live, mutable binding — it is a plain const, a number, fixed for the lifetime of that render.

function Counter() {
  const [count, setCount] = useState(0);

  // This `handleClick` closes over THIS render's `count`.
  function handleClick() {
    console.log(count); // the value from the render that created this fn
  }

  return <button onClick={handleClick}>{count}</button>;
}

On the render where count is 3, handleClick is a brand-new function whose count is permanently 3. React attaches the new handleClick as the click handler on every commit, so for synchronous DOM events you never notice — the latest render's closure is the one attached. The trouble starts when something holds onto an old closure.

2. The classic bug: counter in setInterval

Here is the canonical broken version. The interval is set up once and never reads a fresh count.

// BROKEN
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1); // `count` is captured from the first render: 0
    }, 1000);
    return () => clearInterval(id);
  }, []); // empty deps -> effect runs once, closure frozen at count === 0

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

This counts 0 → 1 and then stops climbing. The effect ran once, on the first render, where count was 0. That interval callback is that render's closure forever, so every tick evaluates setCount(0 + 1), i.e. setCount(1). The first tick moves state from 0 to 1 and re-renders. After that, every tick passes 1 again while the state is already 1; React compares the next value with Object.is, sees they are equal, and bails out of re-rendering. The display freezes at 1.

The crisp interview framing: the empty deps array is not the bug, it is the symptom. The effect closed over count; an empty deps array told React "this effect never needs to re-run," so the closure is never refreshed. You have two solid ways out, plus one tempting non-fix.

3. Fix #1 — functional updates

The cleanest fix removes the dependency on the captured value entirely. The updater form of a setter receives the pending state — React's latest queued value — as its argument, so you never need to read count from the closure.

// FIXED with functional update
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(prev => prev + 1); // React supplies the current value
    }, 1000);
    return () => clearInterval(id);
  }, []); // genuinely empty now: the effect reads nothing reactive

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

Because setCount(prev => prev + 1) reads no outer variable, the empty deps array is now correct rather than a band-aid. The interval is created once, runs forever, and React threads the real current value through prev. This is the right tool whenever the new state is a pure function of the old state — increments, toggles, accumulating into a list.

The limitation matters too: a functional update gives you the previous value of that state variable, not previous props or some other piece of state. If the interval needs to read a step prop or a different state variable, the updater form alone will not save you.

4. Fix #2 — useRef for the latest mutable value

When you need the current value of an arbitrary prop or state — not just "the previous value of the thing I'm setting" — a ref is the escape hatch. A ref is a stable container whose .current you mutate; reading it always yields the latest write, regardless of which render's closure you are in.

// FIXED with a ref mirror
function Counter({ step }) {
  const [count, setCount] = useState(0);

  const stepRef = useRef(step);
  useEffect(() => { stepRef.current = step; }); // no deps array: runs after every commit

  useEffect(() => {
    const id = setInterval(() => {
      // Read .current at call time, so we see the latest `step`.
      setCount(prev => prev + stepRef.current);
    }, 1000);
    return () => clearInterval(id);
  }, []);

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

The key move: the stepRef object keeps the same identity across all renders, so the interval's closure — captured at mount — still points at the live container. An effect with no deps array runs after every commit and copies the newest step into stepRef.current. By the time a tick fires, the ref holds whatever step currently is.

This generalizes into a "latest callback ref," the basis of robust useInterval implementations: store the callback in a ref, update it every render, and have a single mount-time interval invoke ref.current(). The interval stays stable (no teardown thrash) while always calling fresh logic.

function useInterval(callback, delay) {
  const savedCallback = useRef(callback);

  useEffect(() => { savedCallback.current = callback; }, [callback]);

  useEffect(() => {
    if (delay == null) return;
    const id = setInterval(() => savedCallback.current(), delay);
    return () => clearInterval(id);
  }, [delay]); // restart only when the cadence changes
}

5. The deps array is a correctness contract

It is tempting to "fix" the original by listing count in the deps array. It does work — but understand what it costs.

// WORKS, but tears down and recreates the interval on every change
useEffect(() => {
  const id = setInterval(() => setCount(count + 1), 1000);
  return () => clearInterval(id);
}, [count]); // re-run whenever count changes

Now each new count re-runs the effect: clear the old interval, create a new one with a fresh closure. It produces correct numbers, but it resets the 1-second timer every time count changes and churns intervals needlessly. For a subscription or WebSocket, this thrash is a real defect, not a style nit.

The principle the deps array encodes: list every reactive value (props, state, context values, and anything derived from them) that the effect reads. The react-hooks/exhaustive-deps lint rule enforces exactly this. When it complains, it has found a value your closure captured. The fix is almost never to silence it — remove the dependency (functional update), move the value into a ref, or genuinely re-run the effect when it changes. A lying deps array is how stale closures get shipped.

6. The same trap outside intervals

Intervals are the textbook case, but the mechanism appears anywhere a closure outlives its render:

  • Manual event listeners. addEventListener('keydown', handler) in a mount-only effect freezes handler's closure. It reads stale state until you add deps (and re-attach) or read from a ref.
  • Async callbacks. A .then() or await continuation after a slow fetch runs with the props and state of the render that launched it — which may be several renders old by the time it resolves.
  • Debounced/throttled functions. Memoizing a debounced function with useMemo or useCallback on an empty deps array captures stale values inside the debounced body.
  • Stale useCallback. A memoized callback handed to a child is only as fresh as its deps array. Wrong deps means the child invokes old logic.

A note on Strict Mode: in development, React intentionally mounts a component, runs its effects, cleans them up, and runs them again. That double-invoke is a dev-only check (it does not happen in production builds) designed to surface effects with missing cleanup — an interval that is never cleared, for instance, will run twice. It does not change closure semantics; it just makes a missing clearInterval louder.

Two forward-looking notes. The React Compiler memoizes for you, but it does not change closure semantics — a value a callback reads is still the value from the render that created it. And in React Server Components, this bug does not arise on the server: a Server Component runs once per request, with no client-side render loop, no useState/useEffect, and no live closures held across re-renders. Stale closures remain a Client Component concern.

Takeaways

  • Every render has its own props and state. Treat the destructured values as constants frozen at that render — because they are.
  • If the next state derives only from the previous state, use setX(prev => …). It reads nothing from the closure, so the deps array stays honestly empty.
  • For the latest prop or other state inside a long-lived callback, mirror it into a ref and read .current at call time.
  • The deps array is a contract, not a knob. List every reactive value the effect reads; never silence exhaustive-deps to escape a stale closure — fix the capture instead.
  • Adding the value to deps is the most expensive fix: it re-runs the effect and recreates subscriptions/intervals. Reach for functional updates or refs first.
  • Strict Mode's dev-only double-invoke exposes effects with missing cleanup; it is not a production behavior and does not alter closure capture.
SharePostLinkedIn

Reader Discussion

2 replies// weighed in

TopNewestAuthor
Add to the thread
Disagree, agree harder, or share your own experience…
Email instead →markdown okbe kind
  1. Rachel Gold· Staff SREAgrees

    the on-call framing throughout this piece is what makes it land. too many infra articles assume you never get paged. those are written by people who never got paged.

    May 27, 2026·3 days later
  2. Omar Khalil· Senior SWEKind words

    this is the third article from this blog I've sent to my team this month. you're cooking. don't switch to crypto.

    May 29, 2026·5 days later

Worked on something similar? Email ducminhldm@gmail.com — I read every one. The good ones become future posts.

Comments seeded · live discussion via email