Build a declarative wrapper around setInterval for React. Raw setInterval is imperative and clashes with React's render model — you have to set it up, tear it down, and somehow keep its callback from going stale. useInterval(callback, delay) hides all of that: it runs callback every delay milliseconds, always calls the latest version of the callback, pauses when delay is null, and cleans itself up on unmount. This is the classic hook popularized by Dan Abramov's Making setInterval Declarative.
function useInterval(callback: () => void, delay: number | null): void;
Passing delay = null pauses the interval. The hook returns nothing.
function Clock() {
const [count, setCount] = useState(0);
const [running, setRunning] = useState(true);
// Pausing is just passing null as the delay.
useInterval(() => setCount((c) => c + 1), running ? 1000 : null);
return (
<div>
<span>{count}</span>
<button onClick={() => setRunning((r) => !r)}>
{running ? 'pause' : 'resume'}
</button>
</div>
);
}
// The interval always runs the LATEST callback, even after the component
// re-renders with a new one — no stale closures.
// delay = 100: tick … tick … tick (every 100ms)
// delay = null: (paused — no ticks)
null means paused. A numeric delay schedules the timer; null skips scheduling entirely. Toggling between them pauses and resumes.1000 to 500 should tear down the old timer and start a new one.You'll wrap setInterval in a hook by splitting the work into two effects — one that keeps the callback fresh, and one that owns the timer — so the interval always runs the latest callback without restarting on every render.
setInterval predates React and doesn't fit its model. React components re-render constantly, each render creating a new callback closure over the latest props and state. But a timer set up once holds onto whatever closure it captured at setup — so a counter driven by setInterval famously gets "stuck," incrementing off a stale value forever. The fix can't be "re-create the interval whenever the callback changes" either: with an inline callback (new every render) that would tear down and rebuild the timer constantly, and the ticks would never land. We need a timer that stays put across renders yet always calls the current callback — plus a clean way to pause (delay = null) and to restart when the rate changes.
Separate the two concerns into two effects. The first effect's only job is to keep a ref pointed at the latest callback — it runs on every render where the callback changed, but it never touches the timer. The second effect owns the interval: it schedules setInterval once and only re-runs when delay changes. Crucially, the scheduled function doesn't call callback directly — it calls savedCallback.current(), reading the ref at tick time. So the timer is stable, but every tick dispatches to whatever callback is current.
The obvious version sets the interval up once in a mount effect:
const { useEffect } = require('react');
function useInterval(callback, delay) {
useEffect(() => {
const id = setInterval(callback, delay);
return () => clearInterval(id);
}, []); // run once on mount
}
Two things go wrong. First, the empty dependency array captures the callback from the first render and never updates it — so if the component re-renders with a new callback (say, one that closes over fresh state), the interval keeps calling the original, stale one. Second, delay is read once and never reacted to: passing null doesn't pause anything (and setInterval(fn, null) actually coerces the delay to 0, firing as fast as possible), and changing the delay has no effect. Adding [callback, delay] to the deps fixes staleness but introduces a worse bug: with an inline callback that's recreated every render, the effect re-runs every render, tearing down and rebuilding the timer so often it may never tick.
const { useEffect, useRef } = require('react');
function useInterval(callback, delay) {
// A ref that always holds the most recent callback. Updating a ref does not
// re-render and does not touch the timer.
const savedCallback = useRef(callback);
// Keep the ref current. Runs whenever callback changes — cheap, no timer churn.
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
// Own the timer. Re-runs ONLY when delay changes. A null delay means paused,
// so we bail out and schedule nothing. The tick reads savedCallback.current,
// so it always invokes the latest callback even though this effect rarely re-runs.
useEffect(() => {
if (delay === null) return;
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
module.exports = { useInterval };
The key shift is the indirection through savedCallback. The timer is created from delay alone, so it survives re-renders (the common case) and only resets when the rate genuinely changes. The callback freshness is handled separately and cheaply by the first effect. Because the scheduled function calls savedCallback.current() rather than callback, every tick dispatches to the current callback — no stale closure, no needless timer churn.
A null delay is what makes the hook pauseable. Because delay is in the timer effect's dependency array, flipping it to null re-runs the effect — the cleanup clears the existing interval, and the early return schedules nothing. Flipping it back to a number re-runs the effect again and schedules a fresh timer.
Take useInterval(cb1, 100), then a re-render to useInterval(cb2, 100):
savedCallback.current is set to cb1. The timer effect runs (delay 100), scheduling setInterval(() => savedCallback.current(), 100).savedCallback.current() — which is cb1 — once.cb2. The first effect runs (its dep callback changed) and sets savedCallback.current = cb2. The timer effect does not re-run, because delay is still 100 — the same interval keeps running.savedCallback.current() — now cb2. The latest callback ran, and the timer was never interrupted.null. The timer effect re-runs: cleanup calls clearInterval, then the early return schedules nothing. The interval is paused; no more ticks until delay becomes a number again.useEffect(() => { setInterval(callback, delay) }, []) freezes the first callback forever, so updates that should reflect new state never do. Fix: store the callback in a ref updated by its own effect, and have the tick read savedCallback.current().callback in the timer effect's deps. This restarts the timer every time the callback changes — and with an inline callback (new each render) it thrashes so badly the interval may never fire. Fix: the timer effect depends on [delay] only; the ref handles freshness.null like 0. setInterval(fn, null) coerces the delay to 0 and fires continuously instead of pausing. Fix: explicitly if (delay === null) return; before scheduling, so a null delay schedules nothing.return () => clearInterval(id), old timers pile up on every delay change and keep firing after unmount. Fix: always clear the interval in the effect's cleanup.useTimeout. The same ref-for-freshness pattern wraps setTimeout for a one-shot delayed callback that you can cancel or reschedule by changing the delay.{ start, stop, reset } (or a isRunning flag) turns the passive interval into one a component can drive imperatively, useful for stopwatches.requestAnimationFrame instead. For visual/animation work, swapping setInterval for a requestAnimationFrame loop ties ticks to the display refresh and pauses automatically in background tabs.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Build a declarative wrapper around setInterval for React. Raw setInterval is imperative and clashes with React's render model — you have to set it up, tear it down, and somehow keep its callback from going stale. useInterval(callback, delay) hides all of that: it runs callback every delay milliseconds, always calls the latest version of the callback, pauses when delay is null, and cleans itself up on unmount. This is the classic hook popularized by Dan Abramov's Making setInterval Declarative.
function useInterval(callback: () => void, delay: number | null): void;
Passing delay = null pauses the interval. The hook returns nothing.
function Clock() {
const [count, setCount] = useState(0);
const [running, setRunning] = useState(true);
// Pausing is just passing null as the delay.
useInterval(() => setCount((c) => c + 1), running ? 1000 : null);
return (
<div>
<span>{count}</span>
<button onClick={() => setRunning((r) => !r)}>
{running ? 'pause' : 'resume'}
</button>
</div>
);
}
// The interval always runs the LATEST callback, even after the component
// re-renders with a new one — no stale closures.
// delay = 100: tick … tick … tick (every 100ms)
// delay = null: (paused — no ticks)
null means paused. A numeric delay schedules the timer; null skips scheduling entirely. Toggling between them pauses and resumes.1000 to 500 should tear down the old timer and start a new one.You'll wrap setInterval in a hook by splitting the work into two effects — one that keeps the callback fresh, and one that owns the timer — so the interval always runs the latest callback without restarting on every render.
setInterval predates React and doesn't fit its model. React components re-render constantly, each render creating a new callback closure over the latest props and state. But a timer set up once holds onto whatever closure it captured at setup — so a counter driven by setInterval famously gets "stuck," incrementing off a stale value forever. The fix can't be "re-create the interval whenever the callback changes" either: with an inline callback (new every render) that would tear down and rebuild the timer constantly, and the ticks would never land. We need a timer that stays put across renders yet always calls the current callback — plus a clean way to pause (delay = null) and to restart when the rate changes.
Separate the two concerns into two effects. The first effect's only job is to keep a ref pointed at the latest callback — it runs on every render where the callback changed, but it never touches the timer. The second effect owns the interval: it schedules setInterval once and only re-runs when delay changes. Crucially, the scheduled function doesn't call callback directly — it calls savedCallback.current(), reading the ref at tick time. So the timer is stable, but every tick dispatches to whatever callback is current.
The obvious version sets the interval up once in a mount effect:
const { useEffect } = require('react');
function useInterval(callback, delay) {
useEffect(() => {
const id = setInterval(callback, delay);
return () => clearInterval(id);
}, []); // run once on mount
}
Two things go wrong. First, the empty dependency array captures the callback from the first render and never updates it — so if the component re-renders with a new callback (say, one that closes over fresh state), the interval keeps calling the original, stale one. Second, delay is read once and never reacted to: passing null doesn't pause anything (and setInterval(fn, null) actually coerces the delay to 0, firing as fast as possible), and changing the delay has no effect. Adding [callback, delay] to the deps fixes staleness but introduces a worse bug: with an inline callback that's recreated every render, the effect re-runs every render, tearing down and rebuilding the timer so often it may never tick.
const { useEffect, useRef } = require('react');
function useInterval(callback, delay) {
// A ref that always holds the most recent callback. Updating a ref does not
// re-render and does not touch the timer.
const savedCallback = useRef(callback);
// Keep the ref current. Runs whenever callback changes — cheap, no timer churn.
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
// Own the timer. Re-runs ONLY when delay changes. A null delay means paused,
// so we bail out and schedule nothing. The tick reads savedCallback.current,
// so it always invokes the latest callback even though this effect rarely re-runs.
useEffect(() => {
if (delay === null) return;
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
module.exports = { useInterval };
The key shift is the indirection through savedCallback. The timer is created from delay alone, so it survives re-renders (the common case) and only resets when the rate genuinely changes. The callback freshness is handled separately and cheaply by the first effect. Because the scheduled function calls savedCallback.current() rather than callback, every tick dispatches to the current callback — no stale closure, no needless timer churn.
A null delay is what makes the hook pauseable. Because delay is in the timer effect's dependency array, flipping it to null re-runs the effect — the cleanup clears the existing interval, and the early return schedules nothing. Flipping it back to a number re-runs the effect again and schedules a fresh timer.
Take useInterval(cb1, 100), then a re-render to useInterval(cb2, 100):
savedCallback.current is set to cb1. The timer effect runs (delay 100), scheduling setInterval(() => savedCallback.current(), 100).savedCallback.current() — which is cb1 — once.cb2. The first effect runs (its dep callback changed) and sets savedCallback.current = cb2. The timer effect does not re-run, because delay is still 100 — the same interval keeps running.savedCallback.current() — now cb2. The latest callback ran, and the timer was never interrupted.null. The timer effect re-runs: cleanup calls clearInterval, then the early return schedules nothing. The interval is paused; no more ticks until delay becomes a number again.useEffect(() => { setInterval(callback, delay) }, []) freezes the first callback forever, so updates that should reflect new state never do. Fix: store the callback in a ref updated by its own effect, and have the tick read savedCallback.current().callback in the timer effect's deps. This restarts the timer every time the callback changes — and with an inline callback (new each render) it thrashes so badly the interval may never fire. Fix: the timer effect depends on [delay] only; the ref handles freshness.null like 0. setInterval(fn, null) coerces the delay to 0 and fires continuously instead of pausing. Fix: explicitly if (delay === null) return; before scheduling, so a null delay schedules nothing.return () => clearInterval(id), old timers pile up on every delay change and keep firing after unmount. Fix: always clear the interval in the effect's cleanup.useTimeout. The same ref-for-freshness pattern wraps setTimeout for a one-shot delayed callback that you can cancel or reschedule by changing the delay.{ start, stop, reset } (or a isRunning flag) turns the passive interval into one a component can drive imperatively, useful for stopwatches.requestAnimationFrame instead. For visual/animation work, swapping setInterval for a requestAnimationFrame loop ties ticks to the display refresh and pauses automatically in background tabs.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.