August 15, 2026
React useInterval Hook: setInterval Without Stale Closures (2026)
Every React developer writes this component once, and it never does what they expect:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, []);
return <h1>{count}</h1>;
}
It goes 0, 1… and stays at 1 forever. The interval callback was created on the first render, where count was 0, and an empty dependency array means it never sees another render. setCount(0 + 1) runs every second and nothing changes. This is the single most searched React timer bug, and the “fixes” people find — add count to the deps (now the interval is torn down and recreated every second), use the functional updater (works, until the callback needs anything other than the previous count) — all fight the underlying mismatch: setInterval is imperative and lives outside React’s render cycle, while everything it wants to read lives inside it.
Dan Abramov’s 2019 essay Making setInterval Declarative with React Hooks gave the mismatch a proper solution: a useInterval hook that keeps the latest callback in a ref and never restarts the timer just because your component re-rendered. useInterval from @reactuses/core is that idea, plus the pieces you end up needing in a real app — null to pause, pause() / resume() controls, an immediate option, and cleanup that survives StrictMode. This post covers how it works, the two ways to pause, dynamic polling intervals, the background-tab problem, and when a different timer hook is the right call.
Quick Start
npm install @reactuses/core
import { useInterval } from "@reactuses/core";
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
useInterval(() => {
setCount(count + 1); // reads the CURRENT count — no functional updater needed
}, 1000);
return <h1>{count}</h1>;
}
That’s the broken component from the intro, fixed by swapping useEffect + setInterval for useInterval. The callback can read any prop or state directly, the timer is created once and cleared on unmount, and there’s no dependency array to get wrong.
The signature is useInterval(callback, delay, options?) — delay in milliseconds, or null to pause — and it returns { isActive, pause, resume } for the cases where you want manual control.
Why setInterval and React Don’t Get Along
Under the hood there are three separate problems, and the hook solves each one differently:
- Stale closures.
setIntervalholds one function reference for its whole life. That function closed over one render’s props and state. Every later render creates a fresh closure — which the running interval never sees. - Restart-on-render. The obvious fix is to make the effect depend on whatever the callback reads:
useEffect(..., [count]). Now the interval is correct, but it is cleared and re-created on every change — the timing resets each time, and with a fast-changing dependency the tick may never fire at all. - Lifecycle. You have to clear the interval on unmount, clear it again on StrictMode’s dev remount, and — the part that turns into a small state machine — decide how to pause it: a second piece of state, an
ifaroundsetInterval, and dependencies that now include the pause flag.
Here’s what a correct hand-rolled version looks like once all three are handled — a ref for the latest callback, an effect keyed only on delay, and null as the pause signal:
function useIntervalManual(callback: () => void, delay: number | null) {
const savedCallback = useRef(callback);
useLayoutEffect(() => {
savedCallback.current = callback; // always the latest render's closure
}, [callback]);
useEffect(() => {
if (delay === null) return;
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
That’s essentially the core of @reactuses/core’s useInterval — it uses useLatest for the ref and adds controls on top. Two properties fall out of the design, and they’re the ones to internalize:
- Changing the callback never restarts the timer. Re-render as often as you like, pass inline arrow functions, read whatever state you want — the ref is updated, the interval keeps its rhythm.
- Changing
delaydoes restart it.delayis the only dependency, so5000 → 1000clears the old interval and starts a fresh one. That resets the phase: the next tick is a fulldelayaway from the moment the change committed. Usually right (it’s how backoff works, below), occasionally surprising if you were expecting the in-flight tick to complete.
Pausing: null vs. pause() / resume()
There are two ways to stop the interval, and picking the right one keeps your component simple.
Declarative — pass null as the delay. When “should this be running?” is derivable from state or props, encode it in the delay expression and let the hook follow:
function LivePrice({ symbol, live }: { symbol: string; live: boolean }) {
const [price, setPrice] = useState<number | null>(null);
useInterval(
async () => setPrice(await fetchPrice(symbol)),
live ? 5000 : null, // false → paused, true → polling every 5s
);
return <span>{price ?? "—"}</span>;
}
Flip live and the interval clears or restarts. No effect, no ref, no extra state.
Imperative — controls: true and the returned handles. When starting and stopping is a user action rather than a derived condition (a Start/Stop button, “pause while this modal is open”), opt out of automatic starting and drive it yourself:
function Stopwatch() {
const [elapsed, setElapsed] = useState(0);
const [running, setRunning] = useState(false);
const startedAt = useRef(0);
const { pause, resume } = useInterval(
() => setElapsed(Date.now() - startedAt.current), // read the clock, don't count ticks
100,
{ controls: true }, // don't start on mount — wait for resume()
);
const toggle = () => {
if (running) {
pause();
} else {
startedAt.current = Date.now() - elapsed;
resume();
}
setRunning(!running);
};
return (
<>
<p>{(elapsed / 1000).toFixed(1)}s</p>
<button onClick={toggle}>{running ? "Pause" : "Start"}</button>
</>
);
}
pause and resume have stable identities (safe in dependency arrays and event handlers), and the interval is still cleared on unmount even in controls mode — you can’t leak a timer by forgetting. One honest note: isActive in the return value is a ref (isActive.current), not state — it won’t re-render your component when it flips, which is why the example above keeps its own running state for the button label.
You can also mix the two: without controls, pause() still works as a temporary override, and the next delay change resumes automatically.
immediate: Fire Now, Then Every N ms
setInterval waits a full delay before its first call, which is almost never what you want for polling — the user stares at an empty screen for five seconds. immediate: true runs the callback synchronously when the interval starts, then keeps the schedule:
useInterval(refreshDashboard, 30_000, { immediate: true });
Note that “when the interval starts” includes every delay change — every time the delay value changes, the callback fires once right away and the schedule restarts. That’s handy when a user changes the refresh rate, but it’s exactly wrong for a failure-driven backoff (each widening of the delay would trigger another call on the spot), so leave immediate off there — see the next section.
Real-World Patterns
Polling with backoff
Because delay is a normal value, backoff is just state. On failure, widen the interval; on success, snap back:
function useJobStatus(jobId: string) {
const [status, setStatus] = useState<Job | null>(null);
const [delay, setDelay] = useState<number | null>(2000);
useInterval(async () => {
try {
const job = await getJob(jobId);
setStatus(job);
if (job.done) setDelay(null); // stop polling
else setDelay(2000); // healthy → base rate
} catch {
setDelay((d) => Math.min((d ?? 2000) * 2, 60_000)); // back off, cap at 1 min
}
}, delay);
return status;
}
Each setDelay restarts the interval with the new cadence — and because immediate is off, a failure waits the new, longer delay before trying again, which is the whole point. There’s no timer bookkeeping anywhere in that hook — it’s all “what should the delay be right now?”.
Pause in background tabs (and offline)
Browsers throttle timers in hidden tabs — to roughly once per second, and Chrome drops to once per minute after a tab has been hidden for five minutes. Polling in a background tab therefore both wastes quota and fires at unpredictable times. The fix composes naturally with the null pattern using useDocumentVisibility and useOnline:
const visible = useDocumentVisibility() === "visible";
const online = useOnline();
useInterval(refresh, visible && online ? 10_000 : null, { immediate: true });
When the user comes back, delay flips from null to 10_000, the interval restarts, and immediate fetches fresh data right away — exactly the “resume and catch up” behavior you’d otherwise hand-code with visibilitychange listeners.
Clocks: schedule ticks, don’t count them
setInterval drifts. Over a minute of “every 1000ms” you can lose a second or more, especially in throttled tabs. So don’t accumulate time in the callback — use the interval only to trigger a re-render and read the real clock:
function Clock() {
const [now, setNow] = useState(() => Date.now());
useInterval(() => setNow(Date.now()), 1000);
return <time>{new Date(now).toLocaleTimeString()}</time>;
}
The interval is allowed to be sloppy; the displayed value is always correct because it comes from Date.now(), not from ticks × 1000. Same rule for elapsed-time displays: store a start timestamp, render Date.now() - start.
When Not to Use useInterval
- You want one delayed call, not a repeating one. That’s
useTimeoutFn—const [pending, start, stop] = useTimeoutFn(fn, ms)— oruseTimeoutif all you need is a re-render after N ms. - You’re building a countdown display.
useCountDownalready does the seconds →hh:mm:ssmath and completion callback on top ofuseInterval. - You’re animating. Anything visual that should update every frame belongs in
requestAnimationFrame, which is whatuseRafFnwraps — it syncs to the display refresh rate and pauses automatically in hidden tabs. A 16mssetIntervalis not the same thing. - You’re rate-limiting a handler, not scheduling one. Firing on the trailing edge of user input is
useDebounceFn/useThrottleFnterritory. - The “interval” is really server push. If the server can tell you when something changed, a Server-Sent Events stream via
useEventSourcebeats polling on latency and cost.
| You want… | Reach for |
|---|---|
run fn every N ms, pause with null or pause() | useInterval |
run fn once after N ms, with start / stop | useTimeoutFn |
| re-render once after N ms | useTimeout |
hh:mm:ss countdown from N seconds | useCountDown |
run fn every animation frame | useRafFn |
Gotchas Worth Knowing
- The callback reads the latest committed render. The ref is updated in a layout effect after each render, so a tick that fires mid-render sees the previous committed values — a non-issue in practice, but the reason the hook can’t be “more current than React”.
delaychange = phase reset. Covered above; if you need to change the cadence without dropping the in-flight tick, keep the interval fixed and skip ticks in the callback instead.immediatefires inside the effect, on mount and on everydelaychange. Under React 18+ StrictMode in dev, that means the immediate call happens twice on mount (mount → cleanup → mount). Make it idempotent, as with any effect.asynccallbacks are fine — but overlap is on you. The hook doesn’t wait for a returned Promise. If a fetch can take longer thandelay, guard with an in-flight flag or usenullto pause while a request is pending.- SSR-safe by construction. The timer is created inside an effect, so nothing runs on the server and there’s no
windowaccess to guard.
Takeaways
- The stuck-at-
1counter is a stale-closure bug:setIntervalkeeps the first render’s callback.useIntervalstores the latest callback in a ref, so the timer runs once and always sees current state. - Only
delayrestarts the interval — passnullto pause declaratively, orcontrols: truewithpause()/resume()for user-driven start/stop. immediate: truefires now-then-every-N; backoff is justsetDelay(...); pause polling in hidden or offline tabs by foldinguseDocumentVisibility/useOnlineinto the delay expression.- Never accumulate time in an interval — read
Date.now()— and reach foruseTimeoutFn,useCountDown, oruseRafFnwhen the job isn’t “every N ms, forever”.
useInterval, useTimeoutFn, useCountDown, and 110+ other SSR-safe, TypeScript-first hooks live in @reactuses/core — one install, tree-shakeable, no dependencies to babysit.
npm install @reactuses/core