Custom hook: useInterval
setInterval runs a callback at a fixed cadence. Using it in React means remembering to call clearInterval on unmount, and reaching for extra state any time the interval should pause or restart. This post wraps both concerns in a small custom hook called useInterval.
TL;DR
useEffectstarts the interval and returns a cleanup that callsclearInterval— no manual unmount handling.- The latest callback is kept in a
ref, so the interval keeps ticking at a steady rate even when the component re-renders for unrelated reasons. - Passing
nullas the delay pauses the interval, so you can derive "running" straight from props or state instead of tracking it separately. - Changing the delay restarts the interval automatically, so a dynamic value "just works".
Try changing the delay below — under 50 ms the counter pauses, and raising it again continues from where it left off:
0
The hook
import { useEffect, useRef } from "react"
export const useInterval = (callback: () => void, delay: number | null) => {
const savedCallback = useRef(callback)
useEffect(() => {
savedCallback.current = callback
}, [callback])
useEffect(() => {
if (delay === null) return
const id = setInterval(() => savedCallback.current(), delay)
return () => clearInterval(id)
}, [delay])
}
There are two effects. The first keeps savedCallback pointing at the latest callback on every render. The second owns the timer: while delay is a number an interval is running, and when it becomes null, the early return skips the setup and the previous cleanup tears the timer down.
Splitting it this way matters because the interval only depends on delay. A naive version with [callback, delay] would tear the timer down and recreate it on every render, since the callback is usually a fresh arrow function each time — so a component that re-renders faster than the delay (say, an input firing on each keystroke) could keep resetting the timer and starve it. Reading the callback through a ref instead means the interval is created once per delay and always calls the most recent version. This is the pattern from Dan Abramov's "Making setInterval Declarative with React Hooks". A dynamic delay still needs no special handling — changing it runs the cleanup and starts a fresh interval.
Using the hook
The simplest call site has the same API as setInterval, but without any cleanup boilerplate:
useInterval(() => {
console.log("Another second!")
}, 1000)
The hook really shines when the delay should change at runtime, or when the interval should pause based on some other state. Here's the full component behind the counter above:
import { useInterval } from "hooks/useInterval"
import { type ChangeEvent, useState } from "react"
function Counter() {
const [delay, setDelay] = useState(500)
const [count, setCount] = useState(0)
useInterval(() => setCount((prev) => prev + 1), delay >= 50 ? delay : null)
function handleDelayChange(e: ChangeEvent<HTMLInputElement>) {
setDelay(parseInt(e.target.value, 10))
}
return (
<div>
<label>
Choose delay [ms]
<br />
<input type="number" onChange={handleDelayChange} value={delay} />
</label>
<p>{count}</p>
</div>
)
}
Conditionally passing null is what makes the pause work — useInterval sees the delay change, tears down the old timer, and doesn't start a new one until the value is a number again. No isRunning flag, no extra useEffect, just one expression.
Where it's used on this blog
useInterval powers a handful of other posts here:
- Birthday maths — keeps the "time since" counters ticking.
- Letter game — drives the in-game clock.
- Alphabet game — drives the per-letter timer.
- Disco generator — flashes new random colors on every beat.
