useInterval — setInterval that works correctly with React state and cleanup
Recipe
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.
Working Example
"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:
- TickCounter: Dynamic delay that can be changed, paused (
null), or resumed at runtime - Timer: Full countdown with start/pause/reset and completion callback
- PollingStatus: Polls an API every 5 seconds and stops when the job is complete
Deep Dive
How It Works
- The stale closure problem: A plain
setIntervalcaptures 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. - Ref for callback:
savedCallback.currentis updated every render viauseEffect, so the interval always calls the latest version of the callback. - Separate effect for interval: The interval effect only depends on
delay, so it only restarts when the delay changes, not when the callback changes. This preserves timing. - Null delay = paused: Passing
nullas the delay causes the effect to skipsetIntervalentirely, effectively pausing the timer without losing state. - Cleanup: The effect returns
clearInterval, so changing the delay or unmounting always cleans up the previous interval.
Parameters & Return Values
useInterval
| Parameter | Type | Default | Description |
|---|---|---|---|
callback | () => void | — | Function to call on each tick |
delay | number or null | — | Interval in ms, or null to pause |
useCountdown
| 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 |
Variations
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 clockTypeScript Notes
- The callback type is
() => voidsince interval callbacks typically do not return values. delayacceptsnumber | nullwherenullis the pause signal.- The countdown hook uses named interfaces for both options and return, improving IDE support.
Gotchas
- Async callbacks —
setIntervaldoes 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 recursivesetTimeoutinstead. - Timer drift —
setIntervalguarantees minimum delay, not exact timing. Over minutes, drift accumulates. Fix: Use the wall-clock variation shown above for precision. - Background tab throttling — Browsers throttle timers in background tabs to around 1 second minimum. Fix: Use
requestAnimationFramefor visual updates, or accept the throttling for polling. - Strict Mode double-invoke — React 18 Strict Mode runs effects twice in development. This creates and immediately cleans up an extra interval. Fix: No action needed; this only happens in development and the cleanup works correctly.
- Memory leak — If the component unmounts but
clearIntervalis not called, the callback continues firing. Fix: The effect cleanup handles this; never usesetIntervaloutside of this hook pattern in React.
Alternatives
| 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 |
FAQs
What is the stale closure problem and how does useInterval solve it?
- A plain
setIntervalcaptures the callback at creation time, so it sees stale state values. useIntervalstores the callback in a ref (savedCallback.current) and updates it every render, so the interval always calls the latest version.
How do you pause and resume the interval?
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)Why does the interval effect only depend on delay and not on the callback?
- The callback is stored in a ref, so changes to the callback do not restart the interval.
- This preserves timing: the interval keeps ticking at the same rhythm even when the callback updates.
How does useCountdown know when to stop?
- Inside the interval callback, it checks if
remaining <= 1. - When the countdown reaches zero, it sets
isRunningtofalse(which passesnullas the delay) and callsonComplete.
Gotcha: What happens if an async callback takes longer than the interval?
setIntervaldoes not await async callbacks, so calls will overlap.- Fix: use a flag to skip ticks while a previous call is pending, or use recursive
setTimeoutinstead.
Gotcha: Why does setInterval drift over time, and how can you fix it?
setIntervalguarantees a minimum delay, not exact timing. Over minutes, drift accumulates.- Fix: calculate elapsed time from a start timestamp using
Date.now()instead of counting ticks.
How do browsers handle intervals in background tabs?
- Browsers throttle timers in background tabs to around 1 second minimum.
- For visual updates, use
requestAnimationFrame. For polling, accept the throttling.
What happens with useInterval in React 18 Strict Mode?
- Strict Mode runs effects twice in development, creating and immediately cleaning up an extra interval.
- No action is needed; cleanup works correctly and production behavior is unaffected.
How would you adapt this pattern for a one-time delay (setTimeout)?
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]);
}How does the useCountdown hook expose its state in TypeScript?
- It uses named interfaces (
UseCountdownOptionsandUseCountdownReturn) for both the options and return value. - This provides clear IDE autocompletion and documentation for all properties.
Why is the delay typed as number | null rather than number | undefined?
nullis an explicit "paused" signal that is easy to check:if (delay === null) return.- Using
undefinedcould be ambiguous with a missing or forgotten argument.
Can you change the interval speed dynamically at runtime?
- Yes. When the
delayvalue changes, the effect cleans up the old interval and creates a new one with the updated delay. - The TickCounter example demonstrates this with speed-up and slow-down buttons.
Related
- useFetch — combine with interval for polling
- useDebounce — delay instead of repeat
- useEffect — the underlying cleanup mechanism