React useInterval Hook (Dan Abramov pattern)
Calling setInterval inside useEffect breaks the moment the callback closes over stale state. Dan Abramov's useInterval pattern stores the latest callback in a ref so the timer always fires the freshest version without resetting. This snippet covers the canonical pattern, a pause-aware variant that accepts a null delay, and a useTimeout sibling for one-shot timers.
1,167 views
33
function useInterval(callback, delay) {
const cbRef = useRef(callback);
useEffect(() => { cbRef.current = callback; }, [callback]);
useEffect(() => {
if (delay === null || delay === undefined) return undefined;
const id = setInterval(() => cbRef.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
function useRef(v) { return { current: v }; }
function useEffect(fn) { fn(); }
useInterval(() => console.log('tick'), null);
console.log('hook attached, paused on null delay');The two-effect split is the heart of the pattern. The first effect keeps cbRef.current synced with the latest callback (so closures inside the parent component always see fresh state). The second effect creates the actual setInterval and depends only on delay, so changing the callback does NOT tear down and recreate the timer. Without this split, a counter that increments inside the callback would either hold stale state or restart on every render, both of which are common bugs in hand-rolled useInterval code.
function useIntervalPausable(callback, delay) {
const cbRef = useRef(callback);
useEffect(() => { cbRef.current = callback; }, [callback]);
useEffect(() => {
if (delay === null) return undefined;
const id = setInterval(() => cbRef.current(), delay);
return () => clearInterval(id);
}, [delay]);
}
let count = 0;
useIntervalPausable(() => count++, null);
console.log('paused interval, count starts at:', count);Accepting delay = null as a sentinel for 'pause' keeps the API trivial to drive from React state: useInterval(tick, isRunning ? 1000 : null). The early return undefined skips the timer setup entirely, and switching from a number to null tears down the existing one via the effect cleanup. This is the exact shape Dan Abramov published, and it cleanly handles the start / pause / resume cycles that real apps need (poll while a tab is visible, stop on pause).
function useTimeout(callback, delay) {
const cbRef = useRef(callback);
useEffect(() => { cbRef.current = callback; }, [callback]);
useEffect(() => {
if (delay === null) return undefined;
const id = setTimeout(() => cbRef.current(), delay);
return () => clearTimeout(id);
}, [delay]);
}
useTimeout(() => console.log('one-shot timeout'), null);
console.log('timeout attached, paused');The same ref-plus-effect pattern works for one-shot timers. The differences are tiny: setTimeout instead of setInterval, and the cleanup runs clearTimeout. This shape is useful for delayed reveals (toasts that auto-dismiss after a few seconds), debounced submits where you want to abort the pending submit on prop change, and any animation that needs to fire after a delay. As before, passing null parks the timer indefinitely so it never fires until the delay becomes a number.
