DEV3 Technologies home

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 start with 25 minutes then 5 minutes inside onDone.
  • Use a calendar deadline by computing endsAt from a date and skipping pause.
  • Add document.title updates 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

  1. Save as hooks/useCountdown.ts.
  2. const t = useCountdown(25 * 60_000, () => alert('Break time')).
  3. Render t.minutes, t.seconds and buttons calling t.start(), t.pause(), t.reset().
  4. Check t.status for idle, running, paused or done.

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.

Related snippets