Watchstop
00:00.00
Frameworks

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): StopwatchBinding

useStopwatch

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, and reset keep 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.
  • stopwatch is the owned instance, exposed for passing to a child or reading get() outside render. Do not call destroy() on it yourself; the hook owns that.

Options

OptionTypePurpose
clockClockOwned mode: use this clock instead of detectClock(). Pass createMockClock() in tests.
precisionMsnumberOwned mode: coarsen notify cadence — see Options.
stopwatchStopwatchBorrowed 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.

See Store and Spec.

On this page