Skip to content
DevDepth
← Back to all articles

In-Depth Article

React Stale Closure: Why Your Callback Sees Old State and How to Fix It

Learn why stale closures happen in React, how render snapshots and lexical scope create old-value bugs, and when to use dependencies, updater functions, refs, or useEffectEvent.

Published: Updated: 7 min readreact-internals

If a setInterval, DOM listener, or async callback keeps seeing an old value, React is usually not "failing to update." The callback belongs to an older render snapshot, so it keeps reading the variables that existed when that callback was created.

That is what people mean by a stale closure in React: a long-lived function outlives the render snapshot it closed over.

This is where JavaScript and React collide:

  • JavaScript closures keep access to the lexical scope where a function was defined
  • React function components are re-executed on every render, which creates a new set of bindings each time

If you want nearby internals context, this article pairs well with how React updates move from setState to a DOM commit, why Hooks must keep a stable call order, and how useEffect differs from useLayoutEffect in the commit phase.

1. The symptom: the UI updates, but the callback still sees 0

The classic stale-closure example is an interval inside useEffect:

import { useEffect, useState } from "react";

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

  useEffect(() => {
    const timer = setInterval(() => {
      console.log("tick:", count);
      setCount(count + 1);
    }, 1000);

    return () => clearInterval(timer);
  }, []);

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

At first glance, this looks reasonable. But the interval callback closes over the count from the first render.

So what happens?

  • The first tick reads 0 and schedules setCount(1)
  • React re-renders and the screen shows 1
  • The next tick still reads the old count, which is still 0 inside that callback
  • The callback keeps trying to set 1 again

The same pattern shows up in more places than timers:

  • Event listeners that are registered once
  • Subscription callbacks
  • Async logic that finishes later than the render that created it
  • Effects that intentionally run only once but still read reactive values

2. Why it happens: closures keep old scope, renders create new bindings

The most useful mental model is this:

Every render is a snapshot, and every callback belongs to the snapshot where it was created.

React does not mutate the old count variable inside the old callback. On the next render, React calls the component again and creates a new count binding for that new snapshot.

Here is the slow-motion version of the interval example:

  1. Render 1 runs. count is 0.
  2. useEffect registers an interval. That interval callback closes over render 1's count.
  3. The timer fires. It reads 0, logs 0, and schedules setCount(1).
  4. Render 2 runs. Now there is a new count binding whose value is 1.
  5. The effect does not run again because the dependency array is [].
  6. The old interval keeps running, and it still points to render 1's scope.

That is the root of the bug:

A stale closure is a long-lived callback reading variables from a short-lived render snapshot.

This is not a React bug. It is the natural result of JavaScript lexical scoping plus React's snapshot-based render model.

3. Fix 1: declare the real dependency and let the effect resubscribe

If the effect truly depends on count, the most honest fix is to include it in the dependency array:

useEffect(() => {
  const timer = setInterval(() => {
    console.log("tick:", count);
    setCount(count + 1);
  }, 1000);

  return () => clearInterval(timer);
}, [count]);

Why it works:

  • When count changes, React cleans up the old interval
  • Then React runs the effect again
  • The new interval callback closes over the latest render snapshot

This is the right solution when the subscription or effect setup really should stay synchronized with that changing value.

The tradeoff is that the interval is repeatedly torn down and recreated. For a timer, that can introduce drift or jitter. For many other effects, though, this is exactly the correct behavior.

4. Fix 2: use a functional state update when you only need the previous state

If the callback only needs to compute the next state from the previous one, you do not need to read count from the closure at all:

useEffect(() => {
  const timer = setInterval(() => {
    setCount((current) => current + 1);
  }, 1000);

  return () => clearInterval(timer);
}, []);

This works because you are no longer saying "take the count from this closure and add one." Instead, you are giving React an updater recipe:

When you process this update, take the latest state you have and add one.

That bypasses the stale read completely.

This is usually the cleanest fix when the callback only updates state.

One important limit:

  • If you also need to log the latest count
  • Or compare it with another value
  • Or use it in a request or subscription callback

then the functional updater only solves the state-update part. It does not magically make the rest of the closure fresh.

5. Fix 3: use a ref when the callback must read the latest value without resubscribing

Sometimes you need both of these at the same time:

  • A long-lived callback that should not be recreated on every render
  • Access to the latest committed value

That is where a ref becomes the escape hatch:

import { useEffect, useRef, useState } from "react";

export default function Counter() {
  const [count, setCount] = useState(0);
  const latestCountRef = useRef(count);

  useEffect(() => {
    latestCountRef.current = count;
  }, [count]);

  useEffect(() => {
    const timer = setInterval(() => {
      console.log("tick:", latestCountRef.current);
      setCount((current) => current + 1);
    }, 1000);

    return () => clearInterval(timer);
  }, []);

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

Why it works:

  • The interval is still created once
  • But it closes over the ref object, not over a frozen count binding
  • Reading latestCountRef.current gives you the latest committed value

This is useful for timers, event listeners, and subscriptions that should stay mounted while still reading fresh state.

The tradeoff is that refs are mutable escape hatches. They do not trigger renders and they are easy to overuse. If the effect should really re-subscribe when data changes, the dependency-array solution is still better.

6. Fix 4: use useEffectEvent in modern React for effect-local logic

Modern React also provides useEffectEvent for cases where an Effect needs fresh state inside logic that should not itself cause resubscription:

import { useEffect, useEffectEvent, useState } from "react";

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

  const onTick = useEffectEvent(() => {
    console.log("tick:", count);
    setCount((current) => current + 1);
  });

  useEffect(() => {
    const timer = setInterval(() => {
      onTick();
    }, 1000);

    return () => clearInterval(timer);
  }, []);

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

The important idea is not "this is a trick to hide dependencies." The real idea is:

  • The effect setup stays responsible for subscribing and cleaning up
  • The effect event reads the latest props and state when that effect-triggered logic runs

Use useEffectEvent when the setup is reactive in one way, but some logic inside it needs fresh values without forcing a teardown and re-subscribe cycle.

Unlike refs or state setters, an Effect Event function does not have a stable identity. Keep it local to the Effect flow instead of passing it around or adding it to dependencies.

Two guardrails matter:

  • Use it for logic invoked from Effects or effect-managed callbacks
  • Do not use it to suppress a dependency when the effect setup truly should change

7. How to choose the right fix

If you are not sure which solution fits, start with this table:

SituationBest fixWhy
The effect setup should change when a value changesPut the value in the dependency arraySetup and cleanup stay aligned with the reactive value
You only need previous state to compute next stateFunctional updaterRemoves the stale read completely
A long-lived callback must read the latest valueuseRefGives the callback access to fresh committed data
An Effect needs fresh state inside effect-local logic without resubscribinguseEffectEventSeparates reactive setup from non-reactive logic

In practice, the safest default is:

  • Use honest dependencies first
  • Use functional updaters when the logic is only "next state from previous state"
  • Reach for refs or useEffectEvent only when callback lifetime and data freshness need to be separated

8. Common mistakes that keep stale closures alive

Several debugging dead ends show up again and again:

  • Leaving the dependency array as [] just because "I only want this to run once," even though the callback reads reactive values
  • Switching to a functional updater and assuming that also fixes stale logging, stale requests, or stale comparisons
  • Stuffing everything into refs, even when the effect should actually be recreated on value changes
  • Treating stale closures as a React-only rule instead of a lifetime mismatch between closures and render snapshots

If a callback may outlive the render that created it, always ask one question:

How will this callback reach fresh data after the next render?

Once you ask that early, stale-closure bugs become much easier to prevent.

9. The mental model that stops stale-closure bugs

The cleanest way to reason about stale closures is this:

  • Every render is a snapshot
  • Every callback belongs to one snapshot
  • If the callback lives longer than that snapshot, you need an explicit strategy for freshness

That strategy is usually one of four things:

  • Recreate the callback with new dependencies
  • Avoid the stale read by using a functional state updater
  • Store the latest value in a ref
  • Separate effect-local logic with useEffectEvent

Once that mental model clicks, stale closures stop feeling random. They become a predictable mismatch between callback lifetime and render lifetime.

Reviewed by

DevDepth Editor

Editor and frontend engineering writer

DevDepth publishes practical guides on React, Next.js, TypeScript, frontend architecture, browser APIs, and performance optimization.

Each article should be reviewed for technical accuracy, code clarity, metadata quality, and internal-link fit before it goes live.

Last editorial review: 2026-03-17

Contact the editor