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';exportfunctionStopwatch(){consttimer=useTimer({updateIntervalMs: 100});return(<><output>{(timer.elapsedMilliseconds/1000).toFixed(1)}s</output><buttondisabled={!timer.isIdle}onClick={timer.start}>Start</button><buttondisabled={!timer.isRunning}onClick={timer.pause}>Pause</button><buttondisabled={!timer.isPaused}onClick={timer.resume}>Resume</button><buttononClick={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';exportfunctionAuctionTimer({ auctionId, expiresAt }: {auctionId: string;expiresAt: number;}){consttimer=useTimer({autoStart: true,updateIntervalMs: 1000,endWhen: snapshot=>snapshot.now>=expiresAt,onEnd: ()=>api.closeAuction(auctionId),});constremainingMs=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';consttimer=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`);constauction=awaitapi.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';consttimers=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+.