The Frozen Echo: why your React setInterval keeps logging 0
Kai's timer shows 1 forever while the console keeps saying 0. Raiden, the narrator who sees every render at once, explains the stale closure behind the Frozen Echo and the one-line updater that fixes it.
Told by Raiden, with Kai asking the questions
Raiden: Every sound in web comes back as an echo, Kai. Most echoes fade. The one in your timer froze on the first thing it heard.
console.log inside a setInterval keeps printing 0, the interval is reading count from the first render and nothing else. That is a stale closure, and the fix is one small change: setCount(c => c + 1).
In the world of Domlok this is the work of the Frozen Echo, an icy kodama that lives in callbacks saved for later. It never throws an error. It just keeps repeating the first value it ever heard, long after your state has moved on.
Your guide for this episode is Raiden, the guide of Domlok and the narrator who sees every moment of the story at once. A stale closure is a function stuck in one moment of time, so the one who can see all the renders side by side is the right one to explain it. Kai, as usual, is the one who wrote the timer.
The timer that froze
Kai wants a counter of seconds spent on the page. He keepscount in state and starts an interval once, in an effect with an empty dependency array:
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count); // 0, 0, 0...
setCount(count + 1); // 0 + 1, forever
}, 1000);
return () => clearInterval(id);
}, []);
}KaiThere is only one count. I add one to it every second. What could go wrong?
- Render 1: React calls
Timer. This call has its owncount, and it is0. The effect runs and starts the interval. The arrow function passed tosetIntervalcloses over thatcount. - Tick 1: the callback reads
count(0), logs it, and callssetCount(0 + 1). - Render 2: React calls
Timeragain. This new call has a newcount, and it is1. The dependency array is empty, so the effect does not run again and the interval is not replaced. - Tick 2 and every tick after: the same old callback still sees render 1's
count, so it callssetCount(0 + 1)again. The state is already 1, React has nothing new to show, and the screen stays at 1.
RaidenThere is no single count, Kai. There is one for every render, each frozen at its own moment. Your interval was born in render one, so render one's count is the only one it will ever hear.
Why it happens: each render is a snapshot
A closure is a function bundled together with the variables around it when it was created. In a component,count is a plain const inside one call of your function. Calling setCount does not change that constant; it asks React to render again, and the next render gets a new one.
Any function you keep around for later holds on to the render it was created in. That is why the Frozen Echo is not only a timer bug:
- Listeners added once. A handler attached in an effect with
[]reads state from the render that attached it. - Timeouts. A
setTimeoutstarted before an update still sees the value from before. - Saved callbacks. Socket handlers, debounced functions and anything else stored for later can hold an old render.
KaiSo anything that runs later might be talking to the past.
The fix: ask for the latest
Don't readcount from the closure at all. Pass setCount an updater function. React calls it with the latest pending state and stores whatever you return.
useEffect(() => {
const id = setInterval(() => {
setCount((c) => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);count. Tick 1 gets c = 0 and returns 1, tick 2 gets c = 1 and returns 2, and so on.
When you need to read the value
The updater fixes updates. If the callback also needs to read the latest value (to log it, send it, or compare it), you have three options:| Option | How | Trade-off |
|---|---|---|
| List it as a dependency | }, [count]); |
Each change gives the effect a fresh closure, but the interval is cleared and recreated on every tick. |
| Keep it in a ref | Write ref.current = count in an effect, read ref.current in the callback |
Works in every React version. A ref is one box shared by every render. |
useEffectEvent |
Wrap the reading code in an Effect Event and call it from the interval | React 19.2 and later. Always sees the latest committed props and state, and is not a dependency. |
// a ref that always holds the latest count
const countRef = useRef(count);
useEffect(() => {
countRef.current = count;
});
useEffect(() => {
const id = setInterval(() => {
console.log(countRef.current); // latest value
}, 1000);
return () => clearInterval(id);
}, []);const onTick = useEffectEvent(() => {
console.log(count); // latest committed value
});
useEffect(() => {
const id = setInterval(() => onTick(), 1000);
return () => clearInterval(id);
}, []);react-hooks/exhaustive-deps lint rule switched on. For Kai's timer it warns that useEffect has a missing dependency, count, and even suggests the functional update setCount(c => ...). Don't silence it: it is the Frozen Echo announcing itself.
Catch the Echo in DevTools
1. Look inside the closure. In Chrome DevTools, open the Sources panel, find your component, and set a breakpoint on the line inside the interval callback. When it pauses, open the Scope pane. Under Closure you will seecount: 0.
2. Compare with the component. Open the React DevTools Components tab and select Timer. Its state hook shows 1. Two different values from two different renders: when a callback's closure disagrees with the component, you have found a stale closure.
// paused inside the interval callback: count // 0 (the closure from render 1) // React DevTools, Timer, hooks: // State: 1
KaiOne, two, three, four. It's counting! The echo melted.
Before you merge a callback
- Does a function run later (interval, timeout, listener, socket handler, debounced call) and read props or state?
- If it only updates state from the old value, does it use an updater like
setCount(c => c + 1)? - If it needs to read the latest value, is that value in the dependencies, a ref, or an Effect Event?
- Is
react-hooks/exhaustive-depson, with no disabled warnings hiding a missing dependency?
Next episode: Raiden's Throttle. One keystroke, fifty requests: debounce vs throttle for search inputs and scroll handlers. Think you can melt the Echo yourself? Play Thaw the Frozen Echo on domlok.in and earn seal 08. Watch the episode and the 35-second version on YouTube at @dom-lok and save the carousel on Instagram at @mbd.domlok.Acharya's Rule #8A closure remembers the render it was born in. Update from the previous value.