You write a one-second counter, ship it, and it displays 1 forever. No error, no warning, no crash. This is the single most common React Hooks bug, and once you understand why it happens you will spot it in five other places in your codebase.

The broken code
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // <-- the bug
}, 1000);
return () => clearInterval(id);
}, []); // runs once on mount
return <h1>{count}</h1>;
}
Expected: 0, 1, 2, 3, ...
Actual: 0, then 1, then nothing. Ever.
Why it happens
The mental model that fixes this permanently: every render is a snapshot. Each time your component runs, it creates brand-new local variables — including count — and any function defined in that render closes over that render’s variables.
The effect has [] as its dependency array, so it runs exactly once, during the first render. The arrow function passed to setInterval closed over the count from that first render, where count === 0. That interval callback lives on for the lifetime of the component, and it never gets a newer count. So every tick, forever:
setCount(0 + 1); // 1
React then bails out of re-rendering because the next state (1) is identical to the current state (1). The timer is still firing every second — it is just computing the same answer each time.
This is not a React quirk. It is plain JavaScript closure semantics. React only makes it visible because it re-runs your function body on every render.
Fix 1 — the functional updater (use this 90% of the time)
If all you need is the previous value, never read state from the closure. Ask React for it:
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1); // c is always the current state
}, 1000);
return () => clearInterval(id);
}, []);
setCount accepts an updater function, and React invokes it with the freshest state at the time it is applied. The stale count variable is no longer referenced at all, so the empty dependency array is now genuinely correct — the effect really has no dependencies. One interval, created once, never torn down.
Fix 2 — a ref that mirrors the latest value
Sometimes you actually need to read the value, not just derive the next state from it — logging it, sending it in a request, comparing it against a threshold. A ref is a mutable box that is stable across renders, so a long-lived callback can read the current contents through it:
function Counter({ onMilestone }) {
const [count, setCount] = useState(0);
const countRef = useRef(count);
// keep the box in sync after every commit
useEffect(() => {
countRef.current = count;
}, [count]);
useEffect(() => {
const id = setInterval(() => {
if (countRef.current >= 10) onMilestone(countRef.current);
setCount(c => c + 1);
}, 1000);
return () => clearInterval(id);
}, [onMilestone]);
return <h1>{count}</h1>;
}
Assign to the ref inside an effect rather than directly in the render body — writing to refs during render is not safe under concurrent rendering, since a render can be thrown away before it commits.
Fix 3 — extract a reusable useInterval
When you need this in more than one place, wrap the ref trick once and forget about it. This is the well-known pattern popularised by Dan Abramov:
function useInterval(callback, delay) {
const savedCallback = useRef(callback);
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
if (delay === null) return; // null pauses the timer
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
Usage — note that the callback body can freely read fresh props and state, because a new callback is stored on every render while the interval itself is never recreated:
function Counter() {
const [count, setCount] = useState(0);
const [running, setRunning] = useState(true);
useInterval(() => {
setCount(count + 1); // safe here: this closure is always the latest one
}, running ? 1000 : null);
return (
<>
<h1>{count}</h1>
<button onClick={() => setRunning(r => !r)}>
{running ? 'Pause' : 'Resume'}
</button>
</>
);
}
You get pause/resume for free by passing null as the delay, and the timing stays stable because the interval is only re-created when delay itself changes.
The tempting fix that you should think twice about
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, [count]); // silences the lint rule, works... sort of
This does produce a working counter, and the linter is happy. But it changes the semantics: every state change now destroys the timer and starts a fresh one, which resets the countdown phase. Two consequences worth knowing about:
- The interval drifts, because each tick pays the cost of a teardown plus a full new delay.
- If any dependency changes more frequently than the delay — a value driven by mouse movement, typing, or a parent re-render — the timer is reset before it ever fires, and your callback silently never runs. This failure mode is genuinely hard to debug.
Use it only for short-lived timers where re-subscribing is actually the behaviour you want.
How to spot this bug before it ships
- Keep
react-hooks/exhaustive-depson, and treat a warning as a design question, not noise. Never paste// eslint-disable-next-lineto make it go away — that comment is where this class of bug lives. - Be suspicious of anything created once that outlives the render:
setInterval,setTimeout,addEventListener,WebSockethandlers,IntersectionObserver,requestAnimationFrameloops, and third-party subscription APIs. If such a callback reads state or props, it is reading a frozen snapshot. - Symptom signature: the value updates exactly once and then sticks, or a handler behaves as if the user never interacted with the page.
Takeaways
- Every render creates fresh variables; functions defined in a render capture that render’s values permanently.
- Need the previous state? Use the updater form:
setX(prev => ...). - Need to read a fresh value inside a long-lived callback? Mirror it into a ref inside an effect.
- Doing it repeatedly? Extract a
useInterval-style hook that stores the latest callback in a ref. - An empty dependency array is a promise that the effect reads nothing reactive. Make the promise true instead of silencing the checker.