A lightweight React hooks library for building timers, stopwatches, and real-time clocks with minimal boilerplate.
📚 Docs and live examples: https://crup.github.io/react-timer-hook/
Timers get messy when a product needs pause and resume, countdowns tied to server time, async work, or a screen full of independent rows.
@crup/react-timer-hook keeps the default import small and lets you add only the pieces your screen needs:
- ⏱️
useTimer()from the root package for one lifecycle: stopwatch, countdown, clock, or custom flow. - 🔋 Add schedules, timer groups, duration helpers, and diagnostics only when a screen needs them.
- 🧭
useTimerGroup()from/groupfor many keyed lifecycles with one shared scheduler. - 📡
useScheduledTimer()from/schedulesfor polling and timing context. - 🧩
durationParts()from/durationfor common display math. - 🧪 Tested against rerenders, React Strict Mode, async callbacks, cleanup, and multi-timer screens.
- 🤖 AI-ready docs are available through hosted
llms.txt,llms-full.txt, and an optional MCP docs helper.
npm install @crup/react-timer-hook@latest
pnpm add @crup/react-timer-hook@latestRuntime requirements: Node 18+ and React 18+.
import { useTimer } from '@crup/react-timer-hook';
import { durationParts } from '@crup/react-timer-hook/duration';
import { useTimerGroup } from '@crup/react-timer-hook/group';
import { useScheduledTimer } from '@crup/react-timer-hook/schedules';Each recipe has a live playground and a focused code sample:
- Basic: wall clock, stopwatch, absolute countdown, pausable countdown, OTP resend cooldown, manual controls
- Intermediate: once-only onEnd, polling schedule, autosave heartbeat, poll and cancel, backend event stop, diagnostics
- Advanced: many display countdowns, timer group, group controls, checkout holds, per-item polling, dynamic items, toast auto-dismiss
| Product case | Use | Import | Recipe |
|---|---|---|---|
| Stopwatch, call timer, workout timer | Core | @crup/react-timer-hook |
Stopwatch |
| Wall clock or "last updated" display | Core | @crup/react-timer-hook |
Wall clock |
| Auction, reservation, or job deadline | Core | @crup/react-timer-hook |
Absolute countdown |
| Focus timer or checkout hold that pauses | Core + duration | @crup/react-timer-hook + /duration |
Pausable countdown |
| OTP resend or retry cooldown | Core + duration | @crup/react-timer-hook + /duration |
OTP resend cooldown |
| Backend status polling | Schedules | @crup/react-timer-hook/schedules |
Polling schedule |
| Draft autosave or presence heartbeat | Schedules | @crup/react-timer-hook/schedules |
Autosave heartbeat |
| Polling that can close early | Schedules | @crup/react-timer-hook/schedules |
Poll and cancel |
| Auction list with independent row controls | Timer group | @crup/react-timer-hook/group |
Timer group |
| Checkout holds with independent controls | Timer group | @crup/react-timer-hook/group |
Checkout holds |
| Upload/job dashboard with per-row polling | Timer group + schedules | @crup/react-timer-hook/group |
Per-item polling |
| Toast expiry or runtime item timers | Timer group | @crup/react-timer-hook/group |
Toast auto-dismiss |
See the full use-case guide: https://crup.github.io/react-timer-hook/use-cases/
Design assumptions and runtime limits: https://crup.github.io/react-timer-hook/project/caveats/
import { useTimer } from '@crup/react-timer-hook';
export function Stopwatch() {
const timer = useTimer({ updateIntervalMs: 100 });
return (
<>
<output>{(timer.elapsedMilliseconds / 1000).toFixed(1)}s</output>
<button disabled={!timer.isIdle} onClick={timer.start}>Start</button>
<button disabled={!timer.isRunning} onClick={timer.pause}>Pause</button>
<button disabled={!timer.isPaused} onClick={timer.resume}>Resume</button>
<button onClick={timer.restart}>Restart</button>
</>
);
}Use now for wall-clock deadlines from a server, auction, reservation, or job expiry.
import { useTimer } from '@crup/react-timer-hook';
export function AuctionTimer({ auctionId, expiresAt }: {
auctionId: string;
expiresAt: number;
}) {
const timer = useTimer({
autoStart: true,
updateIntervalMs: 1000,
endWhen: snapshot => snapshot.now >= expiresAt,
onEnd: () => api.closeAuction(auctionId),
});
const remainingMs = Math.max(0, expiresAt - timer.now);
if (timer.isEnded) return <span>Auction ended</span>;
return <span>{Math.ceil(remainingMs / 1000)}s left</span>;
}Schedules run while the timer is active. Slow async work is skipped by default with overlap: 'skip'.
import { useScheduledTimer } from '@crup/react-timer-hook/schedules';
const timer = useScheduledTimer({
autoStart: true,
updateIntervalMs: 1000,
endWhen: snapshot => snapshot.now >= expiresAt,
schedules: [
{
id: 'auction-poll',
everyMs: 5000,
overlap: 'skip',
callback: async (_snapshot, controls, context) => {
console.log(`auction poll fired ${context.firedAt - context.scheduledAt}ms late`);
const auction = await api.getAuction(auctionId);
if (auction.status === 'sold') controls.cancel('sold');
},
},
],
});Use useTimerGroup() when every row needs its own pause, resume, cancel, restart, schedules, or onEnd.
import { useTimerGroup } from '@crup/react-timer-hook/group';
const timers = useTimerGroup({
updateIntervalMs: 1000,
items: auctions.map(auction => ({
id: auction.id,
autoStart: true,
endWhen: snapshot => snapshot.now >= auction.expiresAt,
onEnd: () => api.closeAuction(auction.id),
})),
});| Key | Type | Required | Description |
|---|---|---|---|
autoStart |
boolean |
No | Starts the lifecycle after mount. Defaults to false. |
updateIntervalMs |
number |
No | Render/update cadence in milliseconds. Defaults to 1000. This does not define elapsed time; elapsed time is calculated from timestamps. Use a smaller value like 100 or 20 when the UI needs finer updates. |
endWhen |
(snapshot) => boolean |
No | Ends the lifecycle when it returns true. Use this for countdowns, timeouts, and custom stop conditions. |
onEnd |
(snapshot, controls) => void | Promise<void> |
No | Called once per generation when endWhen ends the lifecycle. restart() creates a new generation. |
onError |
(error, snapshot, controls) => void |
No | Handles sync throws and async rejections from onEnd. Also used as the fallback for schedule callback failures when a schedule does not define onError. |
Import from @crup/react-timer-hook/schedules when you need polling or scheduled side effects.
| Key | Type | Required | Description |
|---|---|---|---|
autoStart |
boolean |
No | Starts the lifecycle after mount. Defaults to false. |
updateIntervalMs |
number |
No | Render/update cadence in milliseconds. Defaults to 1000. Scheduled callbacks can run on their own cadence. |
endWhen |
(snapshot) => boolean |
No | Ends the lifecycle when it returns true. |
onEnd |
(snapshot, controls) => void | Promise<void> |
No | Called once per generation when endWhen ends the lifecycle. |
onError |
(error, snapshot, controls) => void |
No | Handles sync throws and async rejections from onEnd. |
schedules |
TimerSchedule[] |
No | Scheduled side effects that run while the timer is active. Async overlap defaults to skip. |
diagnostics |
TimerDiagnostics |
No | Optional lifecycle and schedule events. No logs are emitted unless you pass a logger. |
| Key | Type | Required | Description |
|---|---|---|---|
id |
string |
No | Stable identifier used in diagnostics events and schedule context. Falls back to the array index. |
everyMs |
number |
Yes | Schedule cadence in milliseconds. Must be positive and finite. |
leading |
boolean |
No | Runs the schedule immediately when the timer starts or resumes into a new generation. Defaults to false. |
overlap |
'skip' | 'allow' |
No | Controls async overlap. Defaults to skip, so a pending callback prevents another run. |
callback |
(snapshot, controls, context) => void | Promise<void> |
Yes | Scheduled side effect. Receives timing context with scheduledAt, firedAt, nextRunAt, overdueCount, and effectiveEveryMs. |
onError |
(error, snapshot, controls, context) => void |
No | Handles sync throws and async rejections from that schedule's callback. Falls back to the timer or item onError when omitted. |
Import from @crup/react-timer-hook/group when many keyed items need independent lifecycle control.
| Key | Type | Required | Description |
|---|---|---|---|
updateIntervalMs |
number |
No | Shared scheduler cadence for the group. Defaults to 1000. |
items |
TimerGroupItem[] |
No | Initial/synced timer item definitions. Each item has its own lifecycle state. |
diagnostics |
TimerDiagnostics |
No | Optional lifecycle and schedule events for group timers. |
| Key | Type | Required | Description |
|---|---|---|---|
id |
string |
Yes | Stable key for the item. Duplicate IDs throw. |
autoStart |
boolean |
No | Starts the item automatically when it is added or synced. Defaults to false. |
endWhen |
(snapshot) => boolean |
No | Ends that item when it returns true. |
onEnd |
(snapshot, controls) => void | Promise<void> |
No | Called once per item generation when that item ends naturally. |
onError |
(error, snapshot, controls) => void |
No | Handles sync throws and async rejections from that item's onEnd. Also used as the fallback for that item's schedule callback failures. |
schedules |
TimerSchedule[] |
No | Per-item schedules with the same contract as useScheduledTimer(). |
| Key | Type | Description |
|---|---|---|
status |
'idle' | 'running' | 'paused' | 'ended' | 'cancelled' |
Current lifecycle state. |
now |
number |
Wall-clock timestamp from Date.now(). Use for clocks and absolute deadlines. |
tick |
number |
Number of render/update ticks produced in the current generation. |
startedAt |
number | null |
Wall-clock timestamp when the current generation started. |
pausedAt |
number | null |
Wall-clock timestamp for the current pause, or null. |
endedAt |
number | null |
Wall-clock timestamp when endWhen ended the lifecycle. |
cancelledAt |
number | null |
Wall-clock timestamp when cancel() ended the lifecycle early. |
cancelReason |
string | null |
Optional reason passed to cancel(reason). |
elapsedMilliseconds |
number |
Active elapsed duration calculated from monotonic time, excluding paused time. |
isIdle |
boolean |
Convenience flag for status === 'idle'. |
isRunning |
boolean |
Convenience flag for status === 'running'. |
isPaused |
boolean |
Convenience flag for status === 'paused'. |
isEnded |
boolean |
Convenience flag for status === 'ended'. |
isCancelled |
boolean |
Convenience flag for status === 'cancelled'. |
start() |
function |
Starts an idle timer. No-op if it is already started. |
pause() |
function |
Pauses a running timer. |
resume() |
function |
Resumes a paused timer from the paused elapsed value. |
reset(options?) |
function |
Resets to idle and zero elapsed time. Pass { autoStart: true } to reset directly into running. |
restart() |
function |
Starts a new running generation from zero elapsed time. |
cancel(reason?) |
function |
Terminal early stop. Does not call onEnd. |
The default import stays small. Add the other pieces only when that screen needs them.
| Piece | Import | Best for | Raw | Gzip | Brotli |
|---|---|---|---|---|---|
| ⏱️ Core | @crup/react-timer-hook |
Stopwatch, countdown, clock, custom lifecycle | 4.44 kB | 1.52 kB | 1.40 kB |
| 🧭 Timer group | @crup/react-timer-hook/group |
Many independent row/item timers | 10.93 kB | 3.83 kB | 3.50 kB |
| 📡 Schedules | @crup/react-timer-hook/schedules |
Polling, cadence callbacks, overdue timing context | 8.62 kB | 3.02 kB | 2.78 kB |
| 🧩 Duration | @crup/react-timer-hook/duration |
days, hours, minutes, seconds, milliseconds |
318 B | 224 B | 192 B |
| 🔎 Diagnostics | @crup/react-timer-hook/diagnostics |
Optional lifecycle and schedule event logging | 105 B | 115 B | 90 B |
| 🤖 MCP docs server | react-timer-hook-mcp |
Optional local docs context for MCP clients and coding agents | 6.95 kB | 2.72 kB | 2.36 kB |
CI writes a size summary to the GitHub Actions UI and posts bundle-size reports on pull requests.
Agents and docs-aware IDEs can use:
- https://crup.github.io/react-timer-hook/llms.txt
- https://crup.github.io/react-timer-hook/llms-full.txt
Optional local MCP docs server:
Use npx if the package is not installed in the current project:
{
"mcpServers": {
"react-timer-hook-docs": {
"command": "npx",
"args": ["-y", "@crup/react-timer-hook@latest"]
}
}
}If the package is installed locally, npm also creates a bin shim in node_modules/.bin:
{
"mcpServers": {
"react-timer-hook-docs": {
"command": "./node_modules/.bin/react-timer-hook-mcp",
"args": []
}
}
}The same bundled and minified server is available at node_modules/@crup/react-timer-hook/dist/mcp/server.js.
It exposes:
react-timer-hook://package
react-timer-hook://api
react-timer-hook://recipesIt also exposes MCP tools that editors are more likely to call directly:
| Tool | Title | Description |
|---|---|---|
get_api_docs |
Get API docs | Returns compact API notes for @crup/react-timer-hook. |
get_recipe |
Get recipe | Returns guidance for a named recipe or use case. |
search_docs |
Search docs | Searches API and recipe notes for a query. |
Issues, recipes, docs improvements, and focused bug reports are welcome.
- Read the docs: https://crup.github.io/react-timer-hook/
- Open an issue: https://github.com/crup/react-timer-hook/issues
- See the contributing guide: ./CONTRIBUTING.md
- Release policy: https://crup.github.io/react-timer-hook/project/release-channels/
The package targets Node 18+ and React 18+.