Watchstop
00:00.00
Core

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 })
  • clock is optional. When omitted, use detectClock().
  • options (StopwatchOptions) is optional. See Options for precisionMs (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:

FieldMeaning
clockInjected Clock
accumulatedElapsed ms from completed run segments
startTimeclock.now() at last start, or unset when stopped
runningWhether a segment is active
listenersSet of subscribed callbacks
destroyedWhether 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)

  • false after construct, stop, reset, and destroy.
  • true only while an active run segment is in progress (same flag as the internal model’s running).
  • 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 still 0), and register with the shared driver for clock. Adapters sync UI running from this notification.

stop(): void

  • If destroyed or not running, no-op.
  • Unregister from the shared driver.
  • Set accumulated = accumulated + (clock.now() - startTime).
  • Clear startTime, set running = false.
  • Notify listeners with the stopped elapsed value.

reset(): void

  • If destroyed, no-op.
  • Unregister from the shared driver if registered.
  • Set accumulated = 0, clear startTime, set running = false.
  • Notify listeners with 0 when the previous visible value was not already 0.

get(): number

  • If destroyed, return the last frozen elapsed (typically accumulated after destroy’s stop), or 0 if never started — pick one and keep it stable: return accumulated after 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 (>= 0 under normal clocks).
  • There is no separate peek() API. React adapters must not use live get as useSyncExternalStore’s getSnapshot — 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, subscribe returns 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 / reset are no-ops.
  • Further subscribe does not attach.
  • get() remains safe and returns the frozen elapsed.

Tick loop

While running:

  1. Register with the shared driver for clock (one clock.schedule per clock identity, not per stopwatch).
  2. On each shared tick: if still running and not destroyed, notify listeners with get() subject to precisionMs coarsening; 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

CaseBehavior
Double startSecond call no-op
Double stopSecond call no-op
reset while runningCancels loop, elapsed → 0, stopped
start after resetFresh segment from 0
destroy while runningStops and freezes elapsed; clears listeners
Listener throwsOther listeners still run
Listener reentrancyAdded listeners miss current wave; removed skip rest of wave; controls allowed; destroy clears so remaining cleared listeners are skipped
cancel after natural fireIdempotent; 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.schedule delay (tracked separately)

On this page