Watchstop
Frameworks

React

@watchstop/react adapter.

Wave 1 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
}

type StopwatchBinding = {
  elapsed: number
  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, start, stop, reset } = useStopwatch()

  return (
    <>
      <p>{elapsed} ms</p>
      <button onClick={start}>Start</button>
      <button onClick={stop}>Stop</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
clockClockUse this clock instead of detectClock(). Pass createMockClock() in tests.

Passing a different clock destroys the current instance and builds a fresh one.

Strict 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.

Sharing one stopwatch across components

Not supported in v1. Each useStopwatch() call constructs and owns its own instance, so two components calling it get two unrelated stopwatches, and there is no supported way to bind to a stopwatch passed through props, context, or a module-level singleton.

Stopwatch is the only Store in core today, so a generic primitive for instances the component does not own would have been speculative. Tracked in issue #6, to be revisited when Countdown / Ticker land. Until then, subscribe to the shared instance directly with Stopwatch.subscribe and hold the value in your own state — and read the normative rule below first, because a hand-rolled useSyncExternalStore binding is exactly where it bites.

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. A readout that only shows whole seconds does about sixty times the work it needs.

A precision-style option that coarsens notifications is deliberately out of scope for this change and will follow separately. Until then, render elapsed in the smallest possible component so the frame-rate re-render does not cascade through a subtree.

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 Agents.

On this page