react-timer-hook
A small React hooks library for timers, stopwatches, countdowns, schedules, duration helpers, and many independent timer lifecycles.
Why this exists
Timers sit in the part of product engineering where small code can create large user-visible mistakes. A resend cooldown fires twice. A cart hold expires early after a tab wakes up. A countdown drifts because state was decremented instead of derived from time. A polling loop overlaps with itself and quietly doubles API load. These are not dramatic architecture failures, but they erode trust.
react-timer-hook is built around the idea that product time should be modeled as a lifecycle, not as an interval callback. The interval is only a render cadence. The source of truth is the clock, the configured deadline, the pause state, and the explicit controls the product exposes.
The common trap
The most common timer implementation is a setInterval inside an effect. It looks cheap because the first screen only needs a number to move once per second. That implementation becomes expensive when the screen needs pause, resume, restart, cleanup, async completion, or many independent timers in a list.
React adds another layer of reality. Effects rerun. Strict Mode can expose assumptions by mounting twice in development. Parent props change while an interval is alive. Closures capture stale values. Cleanup may happen after a callback has already scheduled more work. None of this means React is hostile to timers. It means timers need to be treated as state machines with clear ownership.
The API boundary
The library keeps the basic hook narrow: start, pause, resume, reset, snapshot, elapsed, remaining, and completion. More specialized behavior lives in separate entry points. A product that only needs a stopwatch should not import scheduling, diagnostics, duration helpers, and group coordination. A dashboard that needs many timers should not reinvent keyed lifecycle management.
This modularity is a product decision as much as a bundle decision. Different screens have different timing risk. A checkout deadline, an OTP resend timer, an auction table, and a background polling screen all share time as a domain, but they should not be forced through one large abstraction that hides their different failure modes.
Server time and drift
Many product timers are not actually client-owned. The server decides when a hold expires, when a bid window closes, when a session should refresh, or when an action can be retried. A client timer should present that state, not become a second authority. That means deriving remaining time from an absolute deadline whenever possible and treating wake-up drift as normal browser behavior.
The library's design nudges screens toward derived snapshots. If a browser tab sleeps for thirty seconds, the next tick should not pretend that only one second passed. The product should recompute from now, compare against the configured end condition, and fire completion behavior once.
Where it helps
The package is useful for visible countdowns, stopwatches, cooldowns, polling loops, scheduled callbacks, optimistic UI windows, and row-level timers. The goal is not to make timer code clever. The goal is to make lifecycle choices visible enough that product engineers can reason about what starts, what pauses, what ends, and what gets cleaned up.
The best outcome is boring reliability. When a timer is wrong, users rarely blame a hook. They blame the product. react-timer-hook exists to keep that small piece of infrastructure predictable before it becomes one more local workaround in every screen.
Usage samples
Install
Install the core package first, then import heavier timer shapes only where a screen needs them.
npm install @crup/react-timer-hook Server-owned checkout deadline
Derive remaining time from an absolute deadline instead of decrementing local state. The interval is only the render cadence.
import { useTimer } from "@crup/react-timer-hook";
export function CheckoutHold({ expiresAt }: { expiresAt: string }) {
const timer = useTimer({
autoStart: true,
updateIntervalMs: 1000,
endWhen: ({ now }) => now >= new Date(expiresAt).getTime(),
onEnd: () => {
console.info("checkout hold expired");
},
});
return (
<section>
<p>Hold expires in {timer.remaining.formatted}</p>
<button onClick={timer.reset}>Refresh status</button>
</section>
);
} Cooldown with explicit controls
Use named controls for product states such as pause, resume, reset, and one-shot completion instead of hiding them in effects.
import { useCountdown } from "@crup/react-timer-hook/countdown";
export function ResendCodeCooldown() {
const cooldown = useCountdown({
durationMs: 30_000,
autoStart: true,
});
return (
<button disabled={!cooldown.ended} onClick={cooldown.reset}>
{cooldown.ended ? "Resend code" : "Retry in " + cooldown.remaining.seconds + "s"}
</button>
);
} Polling without accidental overlap
A polling screen should declare what happens when the previous request is still running.
import { useScheduledTimer } from "@crup/react-timer-hook/schedule";
export function JobStatus({ jobId }: { jobId: string }) {
useScheduledTimer({
intervalMs: 5000,
autoStart: true,
overlap: "skip",
task: async () => {
const response = await fetch("/api/jobs/" + jobId);
return response.json();
},
});
return <p>Watching job status...</p>;
}