useInterval Hook
A setInterval that sees the latest callback without restarting the timer when the callback identity changes. Dan Abramov’s ref pattern, named.
- react
- hooks
- timers
- interview
setInterval in a class component was an instance field. In a function component, the interval callback closes over that render’s props and state. If you put callback in the effect deps, every parent render with an inline () => setCount(c => c + 1) clears and restarts the timer, so a 1000 ms interval never quite stays 1000 ms from the original start. If you omit callback and use [], the interval is stable and stale.
Dan Abramov’s declarative interval solves that with two effects and a ref: keep the latest function in savedCallback, and let the timer effect depend only on delay. The interval id stays stable. The function does not.
delay == null pauses. Cleanup clearInterval. That is the whole hook.
- Stale closures and reset timers are the two failure modes. A ref fixes the first without causing the second.
- Effect A:
savedCallback.current = callbackwhenevercallbackchanges. No timer work.- Effect B:
setInterval(() => savedCallback.current(), delay)whendelayis a number. CleanupclearInterval.delay: null(orundefined) skips scheduling. That is pause, not “run as fast as possible.”- Name the pattern. Do not restart the clock because the parent passed a new arrow function.
Keep the interval id stable, swap the callback ref
Naive attempt:
useEffect(() => {
const id = setInterval(callback, delay);
return () => clearInterval(id);
}, [callback, delay]);
callback is usually an inline function. Identity changes every render. The effect re-runs. clearInterval + setInterval resets the cadence. A poll that should fire at :00, :01, :02 starts drifting toward “whenever we last rendered.”
Leave callback out:
useEffect(() => {
const id = setInterval(callback, delay);
return () => clearInterval(id);
}, [delay]); // exhaustive-deps will yell; callback is stale
The interval is stable. It still sees the callback from the render that last changed delay. count inside that function is frozen. You will log 0 forever.
The ref pattern:
import { useEffect, useRef } from 'react';
function useInterval(callback, delay) {
const savedCallback = useRef(callback);
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
if (delay == null) return undefined;
const id = setInterval(() => {
savedCallback.current();
}, delay);
return () => {
clearInterval(id);
};
}, [delay]);
}
function Ticker() {
const [count, setCount] = useState(0);
const [delay, setDelay] = useState(1000);
useInterval(() => {
setCount((c) => c + 1);
}, delay);
return (
<p>
{count}
<button type="button" onClick={() => setDelay(null)}>
Pause
</button>
</p>
);
}
| Piece | Role |
|---|---|
savedCallback |
Mutable box, identity-stable, no re-render on write |
| First effect | Publish the latest function after each commit |
| Second effect | Own the timer subscription; deps are [delay] only |
delay == null |
No setInterval, no leftover id |
This is the same “latest handler, stable subscription” idea as useEventListener and useRef for Values and DOM. Abramov wrote it up as making setInterval declarative: delay is data, the callback is always current.
You can assign savedCallback.current = callback during render instead of in an effect. That is a mutation during render. It usually works for refs that only store a function, and it publishes the callback before any child effect runs. The two-effect version is the one to draw on a whiteboard unless you want to argue concurrent render semantics. Do not mutate state during render here.
Pause, resume, change delay
useInterval(tick, running ? 5000 : null);
When delay becomes null, the second effect’s cleanup clears the interval. When it becomes 5000 again, a new interval starts. You do not resume mid-tick; you start a new 5000 ms window. Say that.
Changing 5000 to 1000 also restarts. That is correct: the period is a dependency of the subscription.
delay: 0 is legal and aggressive. setInterval(fn, 0) is still a macrotask, not “next line,” and browsers clamp nested timers (The Event Loop).
setInterval vs recursive setTimeout
If callback is async and can run longer than delay, setInterval will queue a second tick while the first is still awaiting. You can overlap fetches. A recursive setTimeout waits for the work (or at least for the call to return) before scheduling the next:
useEffect(() => {
if (delay == null) return undefined;
let cancelled = false;
let id;
async function loop() {
await savedCallback.current();
if (!cancelled) id = setTimeout(loop, delay);
}
id = setTimeout(loop, delay);
return () => {
cancelled = true;
clearTimeout(id);
};
}, [delay]);
Use that for polling dashboards (Polling Metrics Dashboard). Use setInterval when the tick is cheap and you want a steady clock. Abort in-flight fetch on cleanup either way.
Footguns
Missing cleanup. Strict Mode runs setup, cleanup, setup. Two intervals fire twice as fast in development. Production remounts (Fast Refresh, navigate) leak timers and keep calling setState after unmount. Always clearInterval.
Putting count in the interval callback without a functional update. setCount(count + 1) closes over count. Prefer setCount((c) => c + 1) so the ref’d callback does not need count at all.
delay as a string. "1000" may coerce, or it may not mean what you think in a wrapper. Coerce to number or reject.
Tests. Real 1000 ms waits make CI slow. Drive the hook with a fake setTimeout / fake timers. Clear All Timeouts is the “who owns these ids” cousin.
Hooks in a condition. if (enabled) useInterval(fn, 1000) is illegal. Pass enabled ? 1000 : null.
Interview out-loud answer
The hard part is not
setInterval. It is getting the latest callback without resetting the timer. I keep the function in a ref, sync that ref in one effect, and start the interval in a second effect that only depends ondelay. The tick always callssavedCallback.current(). Ifdelayis null I do not schedule, which is pause. Cleanup clears the interval so Strict Mode and unmount do not leak. I would attribute that split to Dan Abramov’s declarative interval write-up.
Further reading
- MDN:
setInterval - MDN:
clearInterval - react.dev:
useRef - react.dev:
useEffect - Making setInterval Declarative with React Hooks (Dan Abramov)
Related on this site
- useRef for Values and DOM
- Rules of Hooks
- useEffect Fundamentals
- Custom Hooks Design
- useEffect Dependency Array
- The Event Loop
- Implement a Fake setTimeout
- Clear All Timeouts
- Polling Metrics Dashboard
Related guides
- useCopyToClipboard HookCopy text with navigator.clipboard, fallback execCommand, transient copied state, and permissions that fail in insecure contexts.
- useDebouncedValue HookDebounce a changing value in React without firing effects on every keystroke. Cleanup, leading vs trailing, and the stale timer that updates unmounted state.
- useEventListener HookAttach a window, document, or element listener with a stable handler ref so you do not rebind on every render. Capture option and target changes.
- useIdle HookDetect idle users: reset a timer on input events, fire idle after timeout, and decide whether mount starts the timer immediately.
- useInfiniteScroll HookLoad the next page when a sentinel enters the viewport. IntersectionObserver, hasMore, in-flight guards, and the footer that never intersects.