Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Based on Dan Abramov's useInterval pattern, extended with pause/resume and dynamic delay.
import { useEffect, useRef, useCallback, useState } from "react";
/**
* useInterval
* A declarative setInterval hook. Automatically cleans up on unmount.
* Pass `null` as delay to pause.
*/
function useInterval(
callback: () => void,
delay: number | null
): void {
const savedCallback = useRef(callback);
// Remember the latest callback without restarting the interval
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
if (delay === null) return;
const id = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
/**
* useCountdown
* Countdown timer built on useInterval.
*/
interface UseCountdownOptions {
/** Starting value in seconds */
seconds: number;
/** Interval in ms. Default: 1000 */
interval?: number;
/** Start immediately. Default: false */
autoStart?: boolean;
/** Called when countdown reaches zero */
onComplete?: () => void;
}
interface UseCountdownReturn {
/** Remaining seconds */
remaining: number;
/** Whether the countdown is running */
isRunning: boolean;
/** Start or resume the countdown */
start: () => void;
/** Pause the countdown */
pause: () => void;
/** Reset to the initial value and stop */
reset: () => void;
}
function useCountdown(options: UseCountdownOptions): UseCountdownReturn {
const {
seconds,
interval = 1000,
autoStart = false,
onComplete,
} = options;
const [remaining, setRemaining] = useState(seconds);
const [isRunning, setIsRunning] = useState(autoStart);
const onCompleteRef = useRef(onComplete);
useEffect(() => {
onCompleteRef.current = onComplete;
}, [onComplete]);
useInterval(
() => {
setRemaining((prev) => {
if (prev <= 1) {
setIsRunning(false);
onCompleteRef.current?.();
return 0;
}
return prev - 1;
});
},
isRunning ? interval : null
);
const start = useCallback(() => {
if (remaining > 0) setIsRunning(true);
}, [remaining]);
const pause = useCallback(() => setIsRunning(false), []);
const reset = useCallback(() => {
setIsRunning(false);
setRemaining(seconds);
}, [seconds]);
return { remaining, isRunning, start, pause, reset };
}When to reach for this: You need a timer, polling loop, auto-refresh, or countdown that interacts with React state without suffering from stale closure bugs.
"use client";
// Auto-incrementing counter
function TickCounter() {
const [count, setCount] = useState(0);
const [delay, setDelay] = useState<number | null>(1000);
useInterval(() => {
setCount((c) => c + 1);
}, delay);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setDelay(delay ? null : 1000)}>
{delay ? "Pause" : "Resume"}
</button>
<button onClick={() => setDelay(500)}>Speed Up (500ms)</button>
<button onClick={() => setDelay(2000)}>Slow Down (2s)</button>
</div>
);
}
// Countdown timer
function Timer() {
const { remaining, isRunning, start, pause, reset } = useCountdown({
seconds: 60,
onComplete: () => alert("Time is up!"),
});
const minutes = Math.floor(remaining / 60);
const secs = remaining % 60;
return (
<div>
<p style={{ fontSize: 48, fontFamily: "monospace" }}>
{String(minutes).padStart(2, "0")}:{String(secs).padStart(2, "0")}
</p>
<div style={{ display: "flex", gap: 8 }}>
{!isRunning ? (
<button onClick={start}>Start</button>
) : (
<button onClick={pause}>Pause</button>
)}
<button onClick={reset}>Reset</button>
</div>
</div>
);
}
// API polling
function PollingStatus() {
const [status, setStatus] = useState("unknown");
const [polling, setPolling] = useState(true);
useInterval(
async () => {
try {
const res = await fetch("/api/status");
const data = await res.json();
setStatus(data.status);
if (data.status === "complete") {
setPolling(false); // Stop polling when done
}
} catch {
setStatus("error");
}
},
polling ? 5000 : null
);
return (
<div>
<p>Status: {status}</p>
<p>{polling ? "Polling every 5s..." : "Polling stopped"}</p>
</div>
);
}What this demonstrates:
null), or resumed at runtimesetInterval captures the callback at creation time. If the callback references state, it sees stale values. Dan Abramov's pattern solves this by storing the latest callback in a ref.savedCallback.current is updated every render via useEffect, so the interval always calls the latest version of the callback.delay, so it only restarts when the delay changes, not when the callback changes. This preserves timing.null as the delay causes the effect to skip setInterval entirely, effectively pausing the timer without losing state.clearInterval, so changing the delay or unmounting always cleans up the previous interval.| Parameter | Type | Default | Description |
|---|---|---|---|
callback | () => void | - | Function to call on each tick |
delay | number or null | - | Interval in ms, or null to pause |
| Option | Type | Default | Description |
|---|---|---|---|
seconds | number | - | Starting countdown value |
interval | number | 1000 | Tick interval in ms |
autoStart | boolean | false | Start counting immediately |
onComplete | () => void | - | Called when countdown hits zero |
| Return | Type | Description |
|---|---|---|
remaining | number | Seconds remaining |
isRunning | boolean | Whether the countdown is active |
start | () => void | Start or resume |
pause | () => void | Pause |
reset | () => void | Reset to initial seconds and stop |
useTimeout: The same pattern works for setTimeout:
function useTimeout(callback: () => void, delay: number | null): void {
const savedCallback = useRef(callback);
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
if (delay === null) return;
const id = setTimeout(() => savedCallback.current(), delay);
return () => clearTimeout(id);
}, [delay]);
}Accurate timer: setInterval can drift over time. For precise timers, calculate elapsed time from a start timestamp:
const startRef = useRef(Date.now());
useInterval(() => {
const elapsed = Math.floor((Date.now() - startRef.current) / 1000);
setRemaining(Math.max(0, totalSeconds - elapsed));
}, 100); // Check frequently, calculate from wall clock() => void since interval callbacks typically do not return values.delay accepts number | null where null is the pause signal.setInterval does not await async callbacks. If the callback takes longer than the interval, calls overlap. Fix: Use a flag to skip ticks while a previous call is pending, or use recursive setTimeout instead.setInterval guarantees minimum delay, not exact timing. Over minutes, drift accumulates. Fix: Use the wall-clock variation shown above for precision.requestAnimationFrame for visual updates, or accept the throttling for polling.clearInterval is not called, the callback continues firing. Fix: The effect cleanup handles this; never use setInterval outside of this hook pattern in React.| Package | Hook Name | Notes |
|---|---|---|
usehooks-ts | useInterval, useCountdown | Popular, well-documented |
ahooks | useInterval, useCountDown | Full-featured with formatting |
@uidotdev/usehooks | useInterval | Minimal, same pattern |
react-use | useInterval | Supports immediate first tick |
react-timer-hook | useTimer, useStopwatch | Specialized timer components |
setInterval captures the callback at creation time, so it sees stale state values.useInterval stores the callback in a ref (savedCallback.current) and updates it every render, so the interval always calls the latest version.Pass null as the delay to pause, and a number to resume:
const [delay, setDelay] = useState<number | null>(1000);
useInterval(() => tick(), delay);
// Pause: setDelay(null)
// Resume: setDelay(1000)remaining <= 1.isRunning to false (which passes null as the delay) and calls onComplete.setInterval does not await async callbacks, so calls will overlap.setTimeout instead.setInterval guarantees a minimum delay, not exact timing. Over minutes, drift accumulates.Date.now() instead of counting ticks.requestAnimationFrame. For polling, accept the throttling.Replace setInterval with setTimeout and clearInterval with clearTimeout:
function useTimeout(callback: () => void, delay: number | null) {
const savedCallback = useRef(callback);
useEffect(() => { savedCallback.current = callback; }, [callback]);
useEffect(() => {
if (delay === null) return;
const id = setTimeout(() => savedCallback.current(), delay);
return () => clearTimeout(id);
}, [delay]);
}UseCountdownOptions and UseCountdownReturn) for both the options and return value.null is an explicit "paused" signal that is easy to check: if (delay === null) return.undefined could be ambiguous with a missing or forgotten argument.delay value changes, the effect cleans up the old interval and creates a new one with the updated delay.Reviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥