Stopwatch
Elapsed-time Store built on an injectable Clock.
Stopwatch measures elapsed milliseconds. It implements Store<number> and uses an injected Clock for time and ticks.
Public API
type StopwatchOptions = {
precisionMs?: number
}
class Stopwatch implements Store<number> {
constructor(clock?: Clock, options?: StopwatchOptions)
get running(): boolean
start(): void
stop(): void
reset(): void
get(): number
subscribe(listener: (elapsed: number) => void): () => void
destroy(): void
}Export Stopwatch and StopwatchOptions from @watchstop/core.
Construction
import { Stopwatch, detectClock, createMockClock } from '@watchstop/core'
const live = new Stopwatch()
const explicit = new Stopwatch(detectClock())
const underTest = new Stopwatch(createMockClock())
const secondsUi = new Stopwatch(createMockClock(), { precisionMs: 1000 })clockis optional. When omitted, usedetectClock().options(StopwatchOptions) is optional. See Options forprecisionMs(omit vs set, validation, and when to use it).- Initial elapsed is
0. - Initial state is stopped (not running).
Stopwatches that share the same Clock object share one underlying schedule loop (fan-out per tick). Prefer const clock = createBrowserClock() (or one mock) passed into each instance when you want coalescing. detectClock() / bare new Stopwatch() still create a fresh clock per call.
Internal model (normative)
Implementers should keep equivalent state:
| Field | Meaning |
|---|---|
clock | Injected Clock |
accumulated | Elapsed ms from completed run segments |
startTime | clock.now() at last start, or unset when stopped |
running | Whether a segment is active |
listeners | Set of subscribed callbacks |
destroyed | Whether destroy() has been called |
Scheduling is owned by an internal shared driver keyed by Clock identity (not a public export). While registered and wanting ticks, that driver holds at most one pending schedule handle for the clock.
While running, visible elapsed is:
accumulated + (clock.now() - startTime)While stopped, visible elapsed is accumulated.
Methods
running (getter)
falseafter construct,stop,reset, anddestroy.trueonly while an active run segment is in progress (same flag as the internal model’srunning).- Noun getter over a stored flag — not
isRunning().
start(): void
- If
destroyed, no-op. - If already running, no-op (do not reset
startTime). - Otherwise set
running = true,startTime = clock.now(), notify listeners (even when elapsed is still0), and register with the shared driver forclock. Adapters sync UIrunningfrom this notification.
stop(): void
- If
destroyedor not running, no-op. - Unregister from the shared driver.
- Set
accumulated = accumulated + (clock.now() - startTime). - Clear
startTime, setrunning = false. - Notify listeners with the stopped elapsed value.
reset(): void
- If
destroyed, no-op. - Unregister from the shared driver if registered.
- Set
accumulated = 0, clearstartTime, setrunning = false. - Notify listeners with
0when the previous visible value was not already0.
get(): number
- If destroyed, return the last frozen elapsed (typically
accumulatedafter destroy’s stop), or0if never started — pick one and keep it stable: returnaccumulatedafter destroy completes its internal stop. - If running, return live
accumulated + (clock.now() - startTime)as of the call (not only last tick). Required for vanilla / Node. - If stopped, return
accumulated. - Units: milliseconds as a finite number (
>= 0under normal clocks). - There is no separate
peek()API. React adapters must not use livegetasuseSyncExternalStore’sgetSnapshot— see Store and React.
subscribe(listener: (elapsed: number) => void): () => void
- See Store, including listener reentrancy rules.
- Subscribers fire only on clock ticks and on mutating controls as specified (
start/stop/reset/destroy). - If
destroyed,subscribereturns a no-op unsubscribe and does not retain the listener.
destroy(): void
- Idempotent.
- Stop the tick loop (same as
stop()if running). - Clear all listeners without requiring them to unsubscribe first.
- Further
start/stop/resetare no-ops. - Further
subscribedoes not attach. get()remains safe and returns the frozen elapsed.
Tick loop
While running:
- Register with the shared driver for
clock(oneclock.scheduleper clock identity, not per stopwatch). - On each shared tick: if still running and not destroyed, notify listeners with
get()subject toprecisionMscoarsening; the driver re-schedules while any registered stopwatch still wants ticks.
Browser clocks fire once per frame; timer clocks fire once per intervalMs. Stopwatch does not care which.
A listener may call stop / reset / destroy during notify — that instance unregisters and is not re-scheduled. Mid-wave unregister of one instance must not skip peers already snapshotted for that driver wave.
Edge cases
| Case | Behavior |
|---|---|
Double start | Second call no-op |
Double stop | Second call no-op |
reset while running | Cancels loop, elapsed → 0, stopped |
start after reset | Fresh segment from 0 |
destroy while running | Stops and freezes elapsed; clears listeners |
| Listener throws | Other listeners still run |
| Listener reentrancy | Added listeners miss current wave; removed skip rest of wave; controls allowed; destroy clears so remaining cleared listeners are skipped |
cancel after natural fire | Idempotent; no throw |
Usage
import { Stopwatch } from '@watchstop/core'
const sw = new Stopwatch()
const unsubscribe = sw.subscribe((elapsed) => {
console.log(elapsed)
})
sw.start()
sw.stop()
console.log(sw.get())
sw.reset()
unsubscribe()
sw.destroy()Out of scope
- Pause vs stop distinctions beyond
stop - Lap times
- Countdown / Ticker
- Persistence / multiplayer
- Worker clocks / boundary-aligned
Clock.scheduledelay (tracked separately)