A React component runs useEffect(() => { const id = setInterval(() => setCount(count + 1), 1000); return () => clearInterval(id); }, []) and the counter reaches 1 and then stops. Why, and how would you fix it?
answer
- created once, frozen once
- the timer is fine, the value is not
- 0 + 1 every single tick
- React bails out on unchanged state
- let the updater supply the value
basics
~20 sThe interval callback was created during the first render, so it permanently sees count as 0 and calls setCount(1) every tick. React sees the same value each time and stops re-rendering. Fix it with the functional updater setCount(c => c + 1).
solid answer
~50 sThe empty dependency array means the effect body runs exactly once, during the first commit, so the function handed to `setInterval` closes over `count` from that render — value `0` — and never sees a newer one. Every tick computes `0 + 1` and calls `setCount(1)`. The first tick renders `1`; from then on React compares the next state with the current one using `Object.is`, finds them equal, and bails out of re-rendering, so the number appears frozen. The clean fix is `setCount(c => c + 1)`: the updater receives the latest state from React instead of reading a captured variable, so the empty array becomes an honest claim. Adding `count` to the dependency array also works but tears down and restarts the timer every second, which resets its phase. A ref holding the latest value is a third option and the least obvious one to read.
code
javascript · 19 linesimport { useEffect, useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
// Broken: the callback is welded to count === 0 from the first render.
// useEffect(() => {
// const id = setInterval(() => setCount(count + 1), 1000);
// return () => clearInterval(id);
// }, []);
// Fixed: the updater is handed the latest state by React.
useEffect(() => {
const id = setInterval(() => setCount(c => c + 1), 1000);
return () => clearInterval(id);
}, []);
return count;
}go deeper
Recognize the shape and name the cure: reach for setCount(c => c + 1) whenever new state is computed from old state inside a callback that outlives the render.
Walk the mechanism out loud — the callback captured count at the first render, every tick computes 0 + 1, and React stops re-rendering because the value never changes. Then compare the functional-updater fix with adding count to the dependencies.
Show the diagnostic path: a value that updates once and freezes with no error, confirmed by logging the captured value across ticks. Generalize to any long-lived callback — listeners, subscriptions, debounced handlers — and state which fix you would take to review and why.
Own the convention. Decide when a latest-value ref is acceptable for long-lived subscriptions versus when re-synchronizing on dependencies is worth the churn, and make that choice explicit so effects across the codebase do not each invent their own escape hatch.
## What happens tick by tick The effect body runs once, right after the first commit. At that moment `count` is `0`, and the arrow function passed to `setInterval` is created inside that run, so it refers to that render's `count` binding. React never re-runs the body — the dependency array is empty — so the interval callback that keeps firing every second is forever the one built during render #1. Second 1: the callback computes `0 + 1` and calls `setCount(1)`. State changes from `0` to `1`, React re-renders, and the UI shows 1. Second 2: the *same* callback computes `0 + 1` again and calls `setCount(1)`. React compares the next state to the current state with `Object.is`. They are equal, so React bails out — it may still re-render the component once before settling, but the tree does not change and the number stays at 1. The timer is still running; nothing is broken about the timer. What is broken is that the callback is reading a frozen value. The key sentence for an interview: *the effect did not go stale over time; it was created with a snapshot and never recreated.* ## Fix 1: the functional updater ```js useEffect(() => { const id = setInterval(() => setCount(c => c + 1), 1000); return () => clearInterval(id); }, []); ``` `setCount(c => c + 1)` does not read `count` at all. React invokes the updater with the current state when it processes the update, so the captured render no longer matters. The dependency array is now genuinely empty — the effect really does depend on nothing reactive — and the timer keeps a single, steady schedule for the component's whole lifetime. This is the answer an interviewer is waiting for. ## Fix 2: add count to the dependencies ```js useEffect(() => { const id = setInterval(() => setCount(count + 1), 1000); return () => clearInterval(id); }, [count]); ``` This is *correct* — the lint rule would accept it, and the count advances — but it has a real cost. Every change to `count` runs the cleanup, clears the interval, and starts a fresh one. The counter's timing restarts from zero on every increment, so the visible period drifts, and any interval whose setup is expensive (opening a connection, allocating a worker) is being torn down and rebuilt at the update rate. Recognizing that this fix trades a staleness bug for a churn problem is what separates a middle answer from a junior one. ## Fix 3: a ref holding the latest value ```js const countRef = useRef(count); countRef.current = count; useEffect(() => { const id = setInterval(() => setCount(countRef.current + 1), 1000); return () => clearInterval(id); }, []); ``` A ref object is stable across renders, so reading `countRef.current` inside the callback always yields the newest value written to it. This keeps one long-lived interval *and* reads fresh data, which is why it appears in real code that must not restart a subscription. The tradeoff is that the effect is now deliberately non-reactive: a reader can no longer tell from the dependency array what the effect depends on, and mutating a ref during render is something to keep narrow and obvious. For a plain counter it is over-engineering; the functional updater is strictly better. ## Why this trap is so common It combines two things that feel independent. Developers reason about `setInterval` as "this runs repeatedly, so it must see the current state", because in imperative code the callback would read a mutable variable. In React each render produces its own set of values, and a function created in one render is welded to that render. Anything that outlives a render — a timer, an event listener, a subscription handler, a debounced function — carries that render's snapshot with it. The general rule that falls out: when a long-lived callback must act on current state, either derive the new state from the old one through an updater function, or take the value from a stable container. Do not rely on the closure to "catch up", because it cannot. ## Diagnosing it in the wild The signature is a value that updates once and then freezes, or a subscription handler acting on data from minutes ago, with no error anywhere. Logging the captured value inside the callback shows it never changes across ticks, which immediately points at the dependency array rather than the timer. In React DevTools, the component is simply not re-rendering — consistent with React bailing out on an unchanged state value.
- Why does the counter stop at 1 instead of flickering between values?Because every tick calls `setCount(1)` with the identical value. React compares the pending state with the current state using `Object.is`; when they match it bails out of updating the subtree. The first tick genuinely changed `0` to `1`, so that render happened; after that nothing changes, so nothing re-renders even though the timer keeps firing.
- If you instead add count to the dependency array, what have you actually changed about the timer?You have made the effect re-synchronize on every increment: the cleanup clears the interval and the body starts a new one. The counter advances correctly, but the interval's phase resets each second, so the period is no longer stable, and any expensive setup inside the effect is now repeated at the update rate. It fixes staleness by paying churn.
- Does the same trap apply to an event listener registered inside an effect with an empty array?Yes — it is the same mechanism. The handler is created during the first run of the effect and keeps that render's props and state. If it reads a value that later changes, it acts on the old one. The remedies are identical: derive from previous state with an updater, read through a ref, or list the value as a dependency so the listener is re-registered.
saying these in an interview costs you the question
- Claiming setInterval itself is unreliable or throttled here
- Saying React should re-bind the closure automatically
- Thinking clearInterval in the cleanup is what stops the counting
- Adding count to deps without mentioning that the timer restarts
- Believing the fix requires useCallback around the tick function