Build a Rock‑Solid React Countdown Timer (Hooks, TypeScript, Zero Drift)

Build a robust React countdown timer with hooks and TypeScript: zero-drift updates, pause/resume/reset, accessibility, SSR tips, tests, and examples.

ASOasis
8 min read
Build a Rock‑Solid React Countdown Timer (Hooks, TypeScript, Zero Drift)

Image used for representation purposes only.

Overview

A countdown timer is a deceptively simple UI pattern that touches state management, timing accuracy, accessibility, and server rendering. In this guide, you will build a robust React countdown timer that avoids drift, supports pause/resume/reset, exposes an ergonomic API, and ships with TypeScript types. We will implement the logic as a reusable hook and then wrap it in a presentational component.

Goals and requirements

  • Accurate to the second with minimal drift
  • Pause, resume, and reset controls
  • Works with either a target date/time or a duration
  • Accessible (screen-reader friendly) and keyboard friendly
  • Server-rendering safe (Next.js/RSC-compatible)
  • Typed with TypeScript

Designing the API

We will split concerns into:

  • useCountdown: Encapsulates timing and state.
  • : A presentational component built on the hook.

Hook options:

  • target?: Date | number (epoch ms)
  • durationMs?: number (if target not provided)
  • autoStart?: boolean (default true)
  • intervalMs?: number (default 1000)
  • onComplete?: () => void

Hook output:

  • msLeft: number
  • parts: { days, hours, minutes, seconds, milliseconds }
  • formatted: string
  • percent: number (0..1)
  • isRunning: boolean
  • start(), pause(), resume(), reset(next?)

Component props:

  • Same as hook plus:
    • className?: string
    • ariaLabel?: string
    • liveRegion?: ‘off’ | ‘polite’ | ‘assertive’ (default ‘polite’)

Implementation: the hook

Key idea: compute remaining time from absolute timestamps (Date.now()) rather than incrementing counters. This eliminates cumulative drift from delayed intervals and background tab throttling.

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'

type Target = number | Date

export type CountdownParts = {
  days: number
  hours: number
  minutes: number
  seconds: number
  milliseconds: number
}

export type UseCountdownOptions = {
  target?: Target
  durationMs?: number
  autoStart?: boolean
  intervalMs?: number
  onComplete?: () => void
}

export type UseCountdownReturn = {
  msLeft: number
  parts: CountdownParts
  formatted: string
  percent: number
  isRunning: boolean
  start: () => void
  pause: () => void
  resume: () => void
  reset: (next?: { target?: Target; durationMs?: number; autoStart?: boolean }) => void
}

function toMs(t: Target): number {
  return t instanceof Date ? t.getTime() : t
}

function clamp(n: number, min = 0): number { return n < min ? min : n }

function splitMs(ms: number): CountdownParts {
  const d = Math.floor(ms / 86_400_000)
  const h = Math.floor((ms % 86_400_000) / 3_600_000)
  const m = Math.floor((ms % 3_600_000) / 60_000)
  const s = Math.floor((ms % 60_000) / 1000)
  const mm = Math.floor(ms % 1000)
  return { days: d, hours: h, minutes: m, seconds: s, milliseconds: mm }
}

function pad2(n: number): string { return n < 10 ? `0${n}` : String(n) }

function formatParts(p: CountdownParts): string {
  const core = `${pad2(p.hours)}:${pad2(p.minutes)}:${pad2(p.seconds)}`
  return p.days > 0 ? `${p.days}d ${core}` : core
}

export function useCountdown(options: UseCountdownOptions): UseCountdownReturn {
  const { target, durationMs, autoStart = true, intervalMs = 1000, onComplete } = options

  // Refs to avoid re-renders on tick bookkeeping
  const endRef = useRef<number | null>(null)
  const baseDurationRef = useRef<number>(0)
  const pausedLeftRef = useRef<number>(0)

  // Render state
  const [msLeft, setMsLeft] = useState<number>(() => {
    const initialEnd = target ? toMs(target) : Date.now() + (durationMs ?? 0)
    const left = clamp(initialEnd - Date.now())
    return left
  })
  const [isRunning, setIsRunning] = useState<boolean>(autoStart)

  // Initialize absolute end time and base duration
  useEffect(() => {
    const initialEnd = target ? toMs(target) : Date.now() + (durationMs ?? msLeft)
    endRef.current = autoStart ? initialEnd : null
    baseDurationRef.current = clamp(initialEnd - Date.now())
    pausedLeftRef.current = baseDurationRef.current
    setMsLeft(clamp(initialEnd - Date.now()))
    setIsRunning(autoStart)
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [/* run when external target/duration changes */ target instanceof Date ? target.getTime() : target, durationMs])

  // Tick logic using absolute time to avoid drift
  useEffect(() => {
    if (!isRunning || !endRef.current) return

    const step = Math.max(16, Math.floor(intervalMs)) // min ~1 frame

    const id = setInterval(() => {
      const left = clamp(endRef.current! - Date.now())
      setMsLeft(left)
      if (left === 0) {
        setIsRunning(false)
        clearInterval(id)
        onComplete?.()
      }
    }, step)

    return () => clearInterval(id)
  }, [isRunning, intervalMs, onComplete])

  const start = useCallback(() => {
    if (isRunning) return
    const end = target ? toMs(target) : Date.now() + (durationMs ?? pausedLeftRef.current)
    endRef.current = end
    baseDurationRef.current = clamp(end - Date.now())
    pausedLeftRef.current = baseDurationRef.current
    setMsLeft(clamp(end - Date.now()))
    setIsRunning(true)
  }, [isRunning, target, durationMs])

  const pause = useCallback(() => {
    if (!isRunning) return
    const left = clamp((endRef.current ?? Date.now()) - Date.now())
    pausedLeftRef.current = left
    endRef.current = null
    setMsLeft(left)
    setIsRunning(false)
  }, [isRunning])

  const resume = useCallback(() => {
    if (isRunning) return
    const end = Date.now() + pausedLeftRef.current
    endRef.current = end
    setIsRunning(true)
  }, [isRunning])

  const reset = useCallback((next?: { target?: Target; durationMs?: number; autoStart?: boolean }) => {
    const nTarget = next?.target ?? target
    const nDuration = next?.durationMs ?? durationMs
    const end = nTarget ? toMs(nTarget) : Date.now() + (nDuration ?? baseDurationRef.current)
    endRef.current = next?.autoStart === false ? null : end
    baseDurationRef.current = clamp(end - Date.now())
    pausedLeftRef.current = baseDurationRef.current
    setMsLeft(clamp(end - Date.now()))
    setIsRunning(next?.autoStart === false ? false : true)
  }, [target, durationMs])

  const parts = useMemo(() => splitMs(msLeft), [msLeft])
  const formatted = useMemo(() => formatParts(parts), [parts])
  const percent = useMemo(() => {
    const base = baseDurationRef.current || 1
    return 1 - msLeft / base
  }, [msLeft])

  return { msLeft, parts, formatted, percent, isRunning, start, pause, resume, reset }
}

The presentational component

The component renders the formatted time, exposes minimal props, and includes optional aria-live for screen readers.

import React from 'react'
import { useCountdown, UseCountdownOptions } from './useCountdown'

type Live = 'off' | 'polite' | 'assertive'

export type CountdownProps = UseCountdownOptions & {
  className?: string
  ariaLabel?: string
  liveRegion?: Live
}

export function Countdown({ className, ariaLabel = 'Time remaining', liveRegion = 'polite', ...opts }: CountdownProps) {
  const { formatted } = useCountdown(opts)

  return (
    <time
      className={className}
      aria-label={ariaLabel}
      aria-live={liveRegion}
      dateTime={`P${formatted.replace(/\D/g, '')}T`}
    >
      {formatted}
    </time>
  )
}

Notes:

  • dateTime on time is illustrative; for full ISO 8601 duration output, compute a proper string (e.g., PnDTnHnMnS). For many UIs, the human-readable content is what matters.

Usage examples

Basic countdown to a date 5 minutes from now:

export default function Example() {
  const target = Date.now() + 5 * 60 * 1000
  return <Countdown target={target} />
}

With controls using the hook directly:

import { useCountdown } from './useCountdown'

export function Pomodoro() {
  const { formatted, isRunning, start, pause, resume, reset, percent } = useCountdown({ durationMs: 25 * 60 * 1000, autoStart: false })

  return (
    <div>
      <div style={{ fontVariantNumeric: 'tabular-nums', fontSize: 48 }}>{formatted}</div>
      <progress value={percent} max={1} style={{ width: 240 }} />
      <div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
        <button onClick={start} disabled={isRunning}>Start</button>
        <button onClick={pause} disabled={!isRunning}>Pause</button>
        <button onClick={resume} disabled={isRunning}>Resume</button>
        <button onClick={() => reset({ durationMs: 5 * 60 * 1000 })}>Reset 5m</button>
      </div>
    </div>
  )
}

In Next.js (server components): ensure the hook and component live in a client component file and add ‘use client’ at the top.

'use client'
import { Countdown } from '@/components/Countdown'

export default function Page() {
  return <Countdown durationMs={10_000} />
}

Avoiding drift: why this works

  • We never increment counters. Each tick derives msLeft from endTime - Date.now().
  • Browser throttling or event loop delays cannot accumulate error; the next render catches up instantly.
  • Step size (intervalMs) only affects UI refresh rate, not correctness.

If you need sub-second precision, set intervalMs to 50–100 ms and include milliseconds in the UI.

Accessibility checklist

  • Use a time element so assistive tech recognizes temporal content.
  • Announce changes politely for long timers (aria-live=‘polite’). For urgent last 10 seconds, switch to ‘assertive’.
  • Ensure numeric glyphs don’t shift width: apply font-variant-numeric: tabular-nums.
  • Provide a visible label or aria-label, e.g., Time remaining.

Example with last-10s assertive live region:

function AccessibleCountdown() {
  const { parts, formatted } = useCountdown({ durationMs: 30_000 })
  const urgent = parts.days === 0 && parts.hours === 0 && parts.minutes === 0 && parts.seconds <= 10
  return (
    <time aria-live={urgent ? 'assertive' : 'polite'} aria-label='Time remaining'>
      {formatted}
    </time>
  )
}

Formatting options

The provided formatParts prints either HH:mm:ss or Dd HH:mm:ss. For full customization:

  • Accept a format string, e.g., ‘D[d] HH:mm:ss’.
  • Build a tiny parser to replace tokens with values.
  • Or delegate to a library like date-fns/formatDuration if you already include it.

Minimal token replacement example:

export function formatWith(pattern: string, p: CountdownParts): string {
  return pattern
    .replace(/DD?/, String(p.days))
    .replace(/HH/, pad2(p.hours))
    .replace(/mm/, pad2(p.minutes))
    .replace(/ss/, pad2(p.seconds))
}

Performance tips

  • Re-rendering once per second is fine for most pages. If your app stutters:
    • Render digits using a single text node, not many nested components.
    • Memoize expensive children.
    • If you render dozens of timers, increase intervalMs to 2_000 or coalesce updates in a parent.
  • Keep the tick work minimal; compute derived values (parts, formatted) with useMemo.

SSR and hydration

  • The initial msLeft uses Date.now(), which runs during server render. On hydration, a small discrepancy might appear. This is acceptable for countdowns, but if you want absolute parity:
    • Render a placeholder on the server and compute the first value only on the client via useEffect.

Example client-only initial value:

const [ready, setReady] = useState(false)
useEffect(() => setReady(true), [])
return ready ? <Countdown durationMs={10_000} /> : null

Testing the countdown

Use fake timers to simulate time.

import { describe, expect, it, vi } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useCountdown } from './useCountdown'

describe('useCountdown', () => {
  it('counts down and completes', () => {
    vi.useFakeTimers()
    const onComplete = vi.fn()
    const { result } = renderHook(() => useCountdown({ durationMs: 3000, onComplete }))

    act(() => { vi.advanceTimersByTime(1000) })
    expect(result.current.parts.seconds).toBe(2)

    act(() => { vi.advanceTimersByTime(2000) })
    expect(result.current.msLeft).toBe(0)
    expect(onComplete).toHaveBeenCalledTimes(1)

    vi.useRealTimers()
  })
})

Common pitfalls

  • Using setInterval to decrement state by 1 each second accumulates drift. Always derive from absolute time.
  • Forgetting to clear intervals on unmount causes leaks. The hook’s effect cleanup handles this.
  • Not handling past targets: clamp to 0 to avoid negative values.
  • Janky numerals: enable tabular-nums in CSS.

Production hardening ideas

  • Optional persistence (e.g., localStorage) so a refresh doesn’t reset progress.
  • Optional onTick callback for analytics or beeps (throttle it to once per second).
  • Document title updates during countdown.
  • Animations for digit flips (use CSS transforms, not layout-changing properties).

Wrap-up

You now have a flexible, accurate React countdown timer that resists drift, plays nicely with accessibility and SSR, and is easy to extend. Start with the hook for logic, add the component for convenience, and evolve formatting and features as your UI demands.

Related Posts