React
@watchstop/react adapter.
Adapter over Store via useSyncExternalStore. @watchstop/core is a peer dependency — install both.
Exact public names
useStopwatch is the entire public API.
type UseStopwatchOptions =
| { clock?: Clock; precisionMs?: number }
| { stopwatch: Stopwatch }
type StopwatchBinding = {
elapsed: number
running: boolean
start: () => void
stop: () => void
reset: () => void
stopwatch: Stopwatch
}
declare function useStopwatch(options?: UseStopwatchOptions): StopwatchBindinguseStopwatch
useStopwatch owns a Stopwatch and its teardown, so a component that needs its own timer imports one thing and holds no instance itself.
import { useStopwatch } from '@watchstop/react'
export function Timer() {
const { elapsed, running, start, stop, reset } = useStopwatch()
return (
<>
<p>{elapsed} ms</p>
<button onClick={running ? stop : start}>{running ? 'Stop' : 'Start'}</button>
<button onClick={reset}>Reset</button>
</>
)
}- Construction is inert. Nothing is scheduled until
start(). start,stop, andresetkeep the same identity for the life of the component, so they are safe in dependency arrays and can be passed directly as event handlers.destroy()runs in the effect cleanup on unmount, which cancels the scheduled tick loop and clears listeners.stopwatchis the owned instance, exposed for passing to a child or readingget()outside render. Do not calldestroy()on it yourself; the hook owns that.
Options
| Option | Type | Purpose |
|---|---|---|
clock | Clock | Owned mode: use this clock instead of detectClock(). Pass createMockClock() in tests. |
precisionMs | number | Owned mode: coarsen notify cadence — see Options. |
stopwatch | Stopwatch | Borrowed mode: bind this instance; do not pass clock / precisionMs. |
Passing a different clock or precisionMs in owned mode destroys the current instance and builds a fresh one.
Strict Mode
In owned mode the instance is created with a lazy useRef and torn down in a useEffect cleanup — not useMemo, which React is free to discard. Strict Mode double-invokes mount, so the cleanup destroys the instance and the re-run effect rebuilds it and republishes the new instance to the caller. No destroyed instance and no orphaned ticking stopwatch survives the remount. Borrowed mode skips create/destroy.
Sharing one stopwatch across components
Pass the same core instance into each hook:
import { Stopwatch } from '@watchstop/core'
import { useStopwatch } from '@watchstop/react'
const session = new Stopwatch()
export function SessionChip() {
const { elapsed, running, start, stop, reset } = useStopwatch({
stopwatch: session,
})
// ...
}The adapter never calls destroy() on a borrowed instance. Own teardown yourself when the session ends, or leave a module-level instance alive for the page lifetime.
Do not hand-roll useSyncExternalStore with live get as getSnapshot — use the hook. See the normative rule below.
Re-render cost
elapsed is raw milliseconds delivered at the clock's tick cadence, so a component reading it re-renders roughly 60 times a second under createBrowserClock. Pass precisionMs to coarsen notifies — see Options. Keep elapsed in the smallest possible component when you still want finer UI.
useSyncExternalStore (normative)
Store.get() / Stopwatch.get() returns the live value. React requires getSnapshot to be stable between notifications.
@watchstop/react MUST NOT pass live store.get as useSyncExternalStore’s getSnapshot.
Cache the value received via subscribe (or a versioned snapshot updated only in the listener). getSnapshot returns that cached snapshot. SSR / server snapshot may use a one-shot get() when the hook mounts.
Do not use useState + manual subscribe as the primary path.