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;
uses setInterval only, no Date.now()-based timestamp math).

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`.
- `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
- While `running`, `remaining` decrements by 1 every 1000ms. Reaching 0 stops
  the timer (`running` flips to `false`) and calls `onComplete` exactly once.
  The interval never goes negative — it 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 interval.
- 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()` clears the interval — it does not let it keep running and discard
  ticks. `remaining` is frozen exactly where it was; nothing decrements while
  paused.
- `reset()` stops the timer 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`.
- `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.
- The decrementing value the interval reads from is kept in a ref, not in the
  interval-creating effect's dependency array — otherwise the timer would be
  torn down and rebuilt on every single tick. Every `setState` call happens
  inside the `setInterval` callback (an async boundary), never synchronously
  in the effect body itself.
- The interval is cleared on unmount and on every `running` transition
  (pause, reset, auto-stop at 0, start) through the same effect's cleanup, so
  there is never more than one live timer.
- 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 resetpause() 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 resetonComplete 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 callbackonComplete (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 interval to be torn down and rebuilt mid-countdown.
  • Stable controlsstart, 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