Hooks
useInterval
A declarative setInterval hook with a nullable delay, an always-fresh callback, and pause/resume controls.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/use-interval.jsonPrompt
Build a React + TypeScript "useInterval" hook (no dependencies beyond React;
uses setInterval only).
Contract
- `useInterval(callback: () => void, delay: number | null, options?: {
immediate?: boolean }): { pause: () => void; resume: () => void; isPaused:
boolean }`.
- `delay: null` means "no timer at all" — nothing is scheduled, which is the
declarative way to stop. `immediate` defaults to false.
- Export both the options and the returned controls interface.
Behavior
- Keep the latest `callback` in a ref, refreshed in a bare effect on every
render. The effect that creates the interval must NOT depend on
`callback` — consumers pass an inline arrow that closes over changing
state, so depending on it would clear and recreate the timer on every
render, resetting the cadence (a "1s" tick that never actually reaches one
second). The interval calls `savedCallback.current()`, so each tick runs
the newest closure while the timer itself is untouched.
- The interval-owning effect depends on `[delay, isPaused, immediate]`: it
returns early when `delay === null` or `isPaused`, otherwise fires the
callback once if `immediate`, creates the interval, and returns a cleanup
that clears it. Changing `delay` therefore rebuilds the timer with the new
cadence; leaving it alone leaves the timer running.
- `pause()`/`resume()` flip a piece of state that the same effect depends on,
so pausing goes through the same clear-and-rebuild path as a delay change —
there is only one place that owns the timer's lifecycle. Pausing clears the
timer rather than letting it run and discarding ticks, so nothing
accumulates while paused.
- `immediate: true` fires on every (re)creation of the interval: mount,
resume, and delay change. Document that explicitly — it is a useful
property for polling (new cadence, fetch now) and a surprising one if a
consumer expected mount-only.
- `clearInterval` runs in the effect cleanup, so unmounting can never leave a
tick firing into a dead component. Nothing touches browser APIs during
render, so the hook is safe in server-rendered trees.
Rendering & styling
- The hook renders nothing. Consumers own the display: a `tabular-nums`
font for anything counting (so digits don't shift width every tick),
semantic tokens for state (`bg-primary` for a running pulse, `bg-muted`
when idle, `text-muted-foreground` for the label), and a real
`aria-pressed` toggle button for pause/resume rather than a bare icon.
- SSR note: the first server render and the first client render both show the
initial value — a countdown must be seeded from props/state, never from
`Date.now()` read during render, or the server and client will disagree.
- Anything that pulses on each tick is decorative: keep it
`motion-reduce:transition-none` (or drop the animation entirely) and make
sure the underlying number/state is readable without it.
Customization levers
- `delay` — drive it from state to change cadence live (fast polling while a
job runs, slow when idle) or set it to `null` to stop; both are plain
renders, no imperative timer calls.
- `immediate` — on for polling that should fetch as soon as it starts, off
for timers where the first tick should come after a full period.
- Controls vs. delay — `pause()`/`resume()` are for user-facing stop/start
buttons; `delay: null` is for "this feature is off". They compose: a
paused hook stays paused across delay changes.
- Drift-sensitive timers — `setInterval` accumulates drift and is throttled
in background tabs; for a countdown that must stay accurate, tick often but
compute the displayed value from a stored start timestamp
(`Date.now() - startedAt`) instead of adding the delay each tick.
- Sibling shapes worth deriving rather than overloading this one: a
`useTimeout` (fire once), and a rAF-based loop for per-frame animation.Concepts
- Latest-callback ref — the canonical fix for the classic stale-closure interval: the timer is created once and reads the newest callback through a ref on each tick, so the consumer can write an inline arrow over changing state without ever resetting the cadence.
- Nullable delay as declarative off — "stopped" is expressed as data (
delay === null) rather than an imperativeclearIntervalcall, which means the timer's existence is a pure function of render state and can never drift out of sync with the UI that toggles it. - One owner for the timer lifecycle — pause, resume, and delay changes all flow through the same effect's cleanup-and-recreate path, so there is exactly one place that calls
setInterval/clearIntervaland no possibility of two live timers. - Pause clears rather than skips — clearing the timer means no work happens while paused and no ticks queue up to fire in a burst on resume; a "run but ignore" implementation would silently keep the CPU busy.
immediatefires on every (re)start — mount, resume and delay change all count as a start, which is what makes a cadence switch feel instant when polling; it is stated as a behaviour, not an edge case, because a mount-only reading of it would be wrong.- Drift and background throttling —
setIntervalis a best-effort scheduler: browsers clamp it in hidden tabs and each tick can land late, so anything user-visible about elapsed time should be derived from timestamps, with the interval only deciding how often to re-render.