Hooks

useTimeout

A declarative one-shot setTimeout hook with a latest-ref callback, a null-to-pause delay, and reset/clear controls.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

export interface UseTimeoutControls {
  /** Cancel the pending timeout and restart it from now with the current `delay`; a no-op while `delay` is `null`. */
  reset: () => void
  /** Cancel the pending timeout without rescheduling, until `delay` changes or `reset()` is called again. */
  clear: () => void
}

/**
 * A declarative one-shot `setTimeout`: pass `null` as `delay` to pause (nothing is
 * scheduled).

Installation

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

Prompt

Build a React + TypeScript "useTimeout" hook (no dependencies beyond React;
uses the browser setTimeout/clearTimeout only).

Contract
- `useTimeout(callback: () => void, delay: number | null): { reset: () => void;
  clear: () => void }`.
- `delay` is in milliseconds. Passing `null` means "paused" — no timer exists
  at all until `delay` becomes a number again (not just a timer that quietly
  never fires).
- `reset` and `clear` are referentially stable across re-renders, so they can
  safely sit in another effect's dependency array or be passed down as props.

Behavior
- The callback is stored in a ref that's updated on every render; the effect
  that actually schedules the timer depends only on `delay`, never on
  `callback`. This is the core difference from a bare `setTimeout` call inside
  a `useEffect([callback, delay])`: consumers almost always pass an inline
  arrow function that closes over changing state, and if that identity were a
  dependency, every render would clear and re-schedule the timer — the delay
  would keep restarting and the callback would never actually fire on time.
  Here, scheduling only reacts to `delay`, while the timer that eventually
  fires always calls whatever the latest callback closure is.
- On mount, and whenever `delay` changes to a non-null number: clear any
  existing timer and schedule a fresh one for `delay` ms that invokes the
  latest callback when it elapses.
- Whenever `delay` is (or becomes) `null`: clear any pending timer and
  schedule nothing.
- `reset()`: clears the current timer (if any) and re-schedules using the
  *current* `delay`, restarting the countdown from right now. If `delay` is
  currently `null`, `reset()` is a no-op — there's nothing to restart.
- `clear()`: clears the current timer and does not reschedule. The timer
  stays canceled until `delay` changes or `reset()` is called again.
- On unmount: the effect's cleanup clears the pending timer, so no `setState`
  ever fires after the consuming component is gone.

Rendering & styling
- The hook renders nothing itself — consumers own all UI. Any visual feedback
  built around it (a shrinking progress bar, a status badge, an auto-dismiss
  banner) should use semantic tokens (`bg-primary`, `text-muted-foreground`,
  `border`) and respect `prefers-reduced-motion` for decorative motion; the
  timer itself must keep firing correctly whether or not such UI is shown.

Customization levers
- Track and return the remaining time (captured from a start timestamp) if a
  consumer needs a visible countdown — deliberately left out here to keep the
  contract minimal; pair this hook with a dedicated countdown hook when a
  numeric readout is the actual point.
- Trigger a toast / analytics call from inside the callback itself rather than
  adding an `onFire` option — the callback is already the extension point.
- Feed `delay` a computed value (message length, remaining quota, a feature
  flag) instead of a literal — the hook only cares that it receives a number
  or `null`, never where that number comes from.

Concepts

  • Latest-ref callback — the timer's scheduling effect never depends on callback, only on delay; a fresh inline callback every render never restarts the clock, yet the fire always invokes the newest closure.
  • Declarative pause via nulldelay={null} means no timer object exists at all, not a timer quietly failing to fire; this is what makes the hook safe to drive from state like open ? 3000 : null.
  • Reset restarts, it doesn't rebuildreset() cancels and re-schedules imperatively using whatever delay currently is, without touching the effect/dependency machinery that drives automatic rescheduling.
  • Cancel without rebuildingclear() cancels the pending fire and stays canceled even though delay hasn't changed; only reset() or an actual delay change re-arms it.
  • One-shot, not repeating — this hook fires at most once per schedule; a repeating cadence is use-interval, and a rendered countdown display is use-countdown.

On This Page