Drift-free countdown timer hook
A React hook that counts down to a deadline using timestamps so it stays accurate through throttled tabs and pauses.
React / Next.jsHooksreacttimercountdownhook
Live preview
Code
import { useCallback, useEffect, useRef, useState } from 'react';
type Status = 'idle' | 'running' | 'paused' | 'done';
export function useCountdown(durationMs: number, onDone?: () => void) {
const [status, setStatus] = useState<Status>('idle');
const [remaining, setRemaining] = useState(durationMs);
const endsAt = useRef(0);
const doneRef = useRef(onDone);
doneRef.current = onDone; // latest callback without restarting the interval
useEffect(() => {
if (status !== 'running') return;
const tick = () => {
// Derive from the clock so throttled or skipped ticks cannot accumulate drift.
const left = Math.max(0, endsAt.current - Date.now());
setRemaining(left);
if (left === 0) {
setStatus('done');
doneRef.current?.();
}
};
const id = setInterval(tick, 250);
tick();
return () => clearInterval(id);
}, [status]);
const start = useCallback(() => {
const from = status === 'paused' ? remaining : durationMs;
endsAt.current = Date.now() + from;
setRemaining(from);
setStatus('running');
}, [status, remaining, durationMs]);
const pause = useCallback(() => {
if (status !== 'running') return;
setRemaining(Math.max(0, endsAt.current - Date.now()));
setStatus('paused');
}, [status]);
const reset = useCallback(() => {
setRemaining(durationMs);
setStatus('idle');
}, [durationMs]);
const total = Math.ceil(remaining / 1000);
return {
status,
remaining,
hours: Math.floor(total / 3600),
minutes: Math.floor((total % 3600) / 60),
seconds: total % 60,
start,
pause,
reset,
};
}
How it works
What it solves
Decrementing a counter once per setInterval tick looks right but drifts: browsers throttle timers in background tabs, laptops sleep, and each tick adds a few milliseconds of delay. After a few minutes the display is visibly wrong. This hook derives the remaining time from the clock instead.
How it works
Running state stores an absolute endsAt timestamp. Each tick calls Date.now() and computes remaining = max(0, endsAt - now), so a delayed or skipped tick can never accumulate error; the next tick simply shows the correct value. The interval runs at 250 ms so the second display changes promptly, and the effect clears it on cleanup.
Pausing stores the remaining milliseconds and drops endsAt; resuming sets a new endsAt from the stored remainder. start accepts a duration, reset restores it. When remaining hits zero the hook flips to a done status and calls an optional onDone exactly once, using a ref to hold the latest callback and avoid restarting the interval when the parent re-renders.
The hook returns formatted parts (hours, minutes, seconds) as numbers so you control padding and layout.
Adapting it
- For a Pomodoro, call
startwith 25 minutes then 5 minutes insideonDone. - Use a calendar deadline by computing
endsAtfrom a date and skipping pause. - Add
document.titleupdates for a visible tab countdown.
Pitfalls
- Do not render
Date.now()during server rendering; the hook starts idle for this reason. - Audio alerts need a prior user gesture to be allowed.
How to use it
- Save as
hooks/useCountdown.ts. const t = useCountdown(25 * 60_000, () => alert('Break time')).- Render
t.minutes,t.secondsand buttons callingt.start(),t.pause(),t.reset(). - Check
t.statusforidle,running,pausedordone.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.