Hooks

useCountdown

A duration-based countdown state machine — seconds-granularity ticking with start/pause/reset and a once-only onComplete.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

export interface UseCountdownOptions {
  /** Start ticking immediately on mount. Defaults to false. */
  autoStart?: boolean
  /** Fires exactly once, the instant `remaining` reaches 0. `reset()` re-arms it. */
  onComplete?: () => void
}

export interface UseCountdownResult {
  /** Seconds left. Ticks down while running, frozen while paused. */
  remaining: number

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/use-countdown.json

Prompt

Build a React + TypeScript "useCountdown" hook (no dependencies beyond React;
remaining is derived from a Date.now() deadline, woken by a self-renewing
setTimeout — not a decrementing setInterval).

Contract
- `useCountdown(seconds: number, options?: { autoStart?: boolean; onComplete?:
  () => void }): { remaining: number; running: boolean; start: () => void;
  pause: () => void; reset: () => void }`.
- `autoStart` defaults to false. `remaining` starts equal to `seconds`.
  `autoStart` with `seconds <= 0` starts not running — there is nothing to
  count.
- `start`, `pause`, and `reset` are referentially stable across re-renders
  (wrapped in `useCallback` with empty dependency arrays) so they can be
  passed straight to `onClick` or dropped into a dependency array.

Behavior
- Deadline, not decrement: on every entry into the running state, anchor
  `deadline = Date.now() + remaining * 1000` (kept in a ref, null while not
  running). Every wake-up recomputes `ceil((deadline - now) / 1000)` instead
  of subtracting 1, because browsers throttle a background tab's timers to
  about once a minute and a decrementing counter under-counts: leave for 60s
  and it would still claim 45 to go.
- Wake-ups are a self-renewing `setTimeout` aimed at the next whole-second
  boundary (`left - (next - 1) * 1000`, floored at 16ms), so there is no
  accumulated drift and no number shown twice because of a few ms of skew.
  Each wake-up clears any pending timeout first, so exactly one chain is
  alive.
- A `visibilitychange` listener recomputes the moment the tab becomes
  visible again, so a returning user never waits for the next throttled tick.
- Reaching 0 clears the deadline, stops the run (`running` flips to `false`)
  and calls `onComplete` exactly once. `remaining` clamps at 0.
- `onComplete` is read through a ref updated on every render (the classic
  latest-ref pattern), so passing a fresh inline arrow closing over changing
  state does not rebuild or restart the timer.
- A "has this run already completed" flag guards `onComplete` so it fires
  once per run. `reset()` clears that flag, re-arming it for the next run.
- `pause()` settles the deadline back into a duration first
  (`ceil((deadline - now) / 1000)`), then clears the deadline and the timer.
  `remaining` is frozen at that value; nothing ticks while paused, and the
  next `start()` re-anchors a new deadline from it.
- `reset()` stops the timer, clears the deadline and sets `remaining` back to
  the current `seconds` argument (read through a ref, so it reflects the
  latest value the caller passed in, not a stale mount-time snapshot), and
  re-arms `onComplete`. It writes the remaining ref synchronously, so
  `reset(); start()` in one handler restarts a full run.
- `start()` (re)starts ticking from the current `remaining`. If `remaining`
  is already 0, it is a no-op until `reset()` runs — calling `start()` alone
  never causes `remaining` to go negative or `onComplete` to refire. When no
  deadline is live (after `reset()` / `pause()` in the same handler) it also
  bumps a run token in the timer effect's dependencies: `running` stays true
  across that batch, and the token is what rebuilds the timer.
- The current remainder is kept in a ref, not in the timer effect's
  dependency array (which is `[running, runToken]`) — otherwise the timer
  would be torn down and rebuilt on every tick. Every `setState` call happens
  inside a timer or event callback, never synchronously in the effect body.
- The timeout and the visibility listener are removed on unmount and on every
  `running` / run-token change through the same effect's cleanup, so there is
  never more than one live timer and nothing fires after unmount.
- Changing the `seconds` argument mid-run does NOT retroactively change the
  in-flight `remaining` — it only changes what a subsequent `reset()` reverts
  to. Keep this intentional and documented rather than surprising.

Rendering & styling
- The hook renders nothing. Consumers own all UI: a `tabular-nums` font on
  any digits so they don't shift width every tick, semantic tokens for state
  (`bg-primary` for a running indicator, `bg-muted` track, `text-muted-
  foreground` for idle/disabled labels), and a real `disabled` state on the
  action button while `running` (or while depleted, for start).
- Any progress bar or pulse driven by the tick is decorative: respect
  `prefers-reduced-motion` (`motion-reduce:transition-none`) and keep the
  underlying number readable without it.

Customization levers
- `autoStart` — on for a cooldown that should begin the moment the component
  mounts (OTP resend right after sending the first code); off when the user
  triggers the first run explicitly.
- `onComplete` — hook side effects here (enable a button, show a toast,
  advance a step) instead of polling `remaining === 0` in a `useEffect`.
- Millisecond granularity — this hook intentionally ticks in whole seconds;
  a variant could accept `msRemaining` and tick every 100ms for a visibly
  smoother progress bar, at the cost of more re-renders.
- Formatting helpers — `remaining` is a raw integer on purpose; add a
  `mm:ss` or `Resend in Ns` formatter at the call site rather than baking a
  format into the hook, since every consumer wants a different string.
- `reset(newSeconds?)` — the default `reset()` keeps the contract minimal by
  reverting to the current `seconds` argument; a consumer that wants a one-
  off override independent of that argument can extend `reset` to accept an
  explicit value instead of re-rendering with a new `seconds` first.

Concepts

  • Duration countdown, not target countdown — this hook counts down a plain number of seconds handed to it (useCountdown(60)); it has no notion of "today at 6pm". The sibling Countdown component owns target-time math and day/hour/minute/second derivation — pick this hook for OTP/exam/promo timers and the component for calendar-style countdowns, never both for the same job.
  • Pause freezes, it doesn't fast-forward or reset — pause() clears the underlying timer so remaining sits exactly where it was; resuming with start() continues from that frozen value instead of losing or replaying elapsed time.
  • Once-only completion, re-armed by reset — onComplete is guarded by an internal flag so a run that reaches 0 notifies exactly once, even under React Strict Mode's double-invoked effects; calling reset() clears that flag so the next full run can complete again.
  • Latest-ref callback — onComplete (and the seconds argument used by reset) are read through refs updated every render, so an inline arrow function that closes over changing component state never causes the timer to be torn down and rebuilt mid-countdown.
  • Stable controls — start, pause, and reset keep the same function identity for the lifetime of the component, so they're safe to pass directly as onClick handlers or list in a useEffect dependency array without triggering extra runs.

On This Page