Made by Dhawal Made by Dhawal

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

The Frozen Echo, a pale icy kodama spirit with hollow eyes and floating zeros

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.

But I add one every second!

If your React timer shows 1 and never moves, or 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 keeps count in state and starts an interval once, in an effect with an empty dependency array:
Timer.jsx
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?

The screen goes from 0 to 1 and stops. The console prints 0 every second. Here is what happens:
  1. Render 1: React calls Timer. This call has its own count, and it is 0. The effect runs and starts the interval. The arrow function passed to setInterval closes over that count.
  2. Tick 1: the callback reads count (0), logs it, and calls setCount(0 + 1).
  3. Render 2: React calls Timer again. This new call has a new count, and it is 1. The dependency array is empty, so the effect does not run again and the interval is not replaced.
  4. Tick 2 and every tick after: the same old callback still sees render 1's count, so it calls setCount(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 setTimeout started 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 read count from the closure at all. Pass setCount an updater function. React calls it with the latest pending state and stores whatever you return.
Timer.jsx
useEffect(() => {
const id = setInterval(() => {
setCount((c) => c + 1);
}, 1000);
return () => clearInterval(id);
}, []);
The interval is still the same old function from render 1, and that is fine: it never needs to know 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.
useLatest.js
// 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);
}, []);
Timer.jsx (React 19.2+)
const onTick = useEffectEvent(() => {
console.log(count); // latest committed value
});
useEffect(() => {
const id = setInterval(() => onTick(), 1000);
return () => clearInterval(id);
}, []);
Keep the 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 see count: 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.
DevTools console
// 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-deps on, with no disabled warnings hiding a missing dependency?
Acharya's Rule #8

A closure remembers the render it was born in. Update from the previous value.

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.

Frequently asked questions

Why does my React setInterval always log 0?+
The interval callback was created during the first render, so it closes over that render’s count, which is 0. With an empty dependency array the effect never runs again, so the callback never sees a newer value.
Why is my React counter stuck at 1?+
Calling setCount(count + 1) inside an interval that only knows count = 0 sets the state to 1 on every tick. The state is already 1 after the first tick, so nothing changes on screen. Use setCount(c => c + 1) so React passes in the latest value.
What is a stale closure in React?+
It is a function that still holds props or state from the render it was created in, after newer renders have happened. It shows up in intervals, timeouts, event listeners added once and other callbacks saved for later.
How do I read the latest state inside setInterval in React?+
Add the value to the effect’s dependencies, keep it in a ref and read ref.current, or, in React 19.2 and later, read it inside a function created with useEffectEvent. If you only need to update state, an updater function like setCount(c => c + 1) is enough.
How can I see a stale closure in Chrome DevTools?+
In the Sources panel, set a breakpoint inside the callback and look at the Closure section of the Scope pane. If it shows an older value than the state shown in React DevTools for the same component, the callback has a stale closure.