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.
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
0and schedulessetCount(1) - React re-renders and the screen shows
1 - The next tick still reads the old
count, which is still0inside that callback - The callback keeps trying to set
1again
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:
- Render 1 runs.
countis0. useEffectregisters an interval. That interval callback closes over render 1'scount.- The timer fires. It reads
0, logs0, and schedulessetCount(1). - Render 2 runs. Now there is a new
countbinding whose value is1. - The effect does not run again because the dependency array is
[]. - 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
countchanges, 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
countbinding - Reading
latestCountRef.currentgives 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:
| Situation | Best fix | Why |
|---|---|---|
| The effect setup should change when a value changes | Put the value in the dependency array | Setup and cleanup stay aligned with the reactive value |
| You only need previous state to compute next state | Functional updater | Removes the stale read completely |
| A long-lived callback must read the latest value | useRef | Gives the callback access to fresh committed data |
| An Effect needs fresh state inside effect-local logic without resubscribing | useEffectEvent | Separates 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
useEffectEventonly 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