Store
Snapshot get and subscribe contract for framework adapters.
Store<T> is the only surface framework adapters need. It is deliberately small so React, Svelte, Vue, Solid, Angular, Qwik, and Alpine can all bind without core knowing about them.
Interface
interface Store<T> {
get(): T
subscribe(listener: (value: T) => void): () => void
}Export this type from @watchstop/core as Store.
Stopwatch implements Store<number> where the value is elapsed milliseconds.
Method contracts
get(): T
- Returns the live current value synchronously (for
Stopwatch, live elapsed — see Stopwatch). - Must be safe to call at any time after construction (including before any subscription).
- Must not notify listeners by itself.
- Required for vanilla / Node and any adopter that polls without waiting for a tick.
- There is no separate
peek()API.
subscribe(listener: (value: T) => void): () => void
- Registers
listenerto be called when the store’s value changes in a way the implementation considers observable. - Returns an unsubscribe function.
- Unsubscribe is idempotent and must not throw.
- Calling
subscribemust not require the listener to be invoked synchronously on subscribe (adapters may callget()themselves for the initial value). - After unsubscribe,
listenermust not be called again for later updates. - Multiple subscribers are supported; each gets the same value notifications independently.
Notification rules (Stopwatch as Store)
Notify all current listeners when elapsed changes due to:
- a running clock tick
start/stop/resetthat changes the visible elapsed value (or as those methods already specify)
Do not notify when:
get()is called- subscribe/unsubscribe alone (no value change)
destroy()after listeners are cleared (no further notifies)
Subscribers fire only on clock ticks and on mutating controls (start / stop / reset / destroy as specified on those methods) — not on every get().
Listener errors: one listener throwing must not prevent other listeners from running. Prefer isolating per-listener try/catch inside notify.
Listener reentrancy (normative)
During a single notify wave:
- Snapshot the listener list (or equivalent) at wave start, or otherwise ensure listeners added during the wave do not receive the current wave.
- Listeners removed (unsubscribed) during the wave are not called again in that wave if they have not already been invoked.
- The current listener invocation always finishes.
start/stop/reset/destroyinvoked from a listener are allowed. They take effect per their method contracts (immediately for state; scheduling/cancel as specified). Prefer:destroyclears listeners so remaining listeners in the same wave that were cleared are skipped if not yet called.
React and useSyncExternalStore (normative)
get() is live. React’s useSyncExternalStore requires getSnapshot to return a value that is referentially/value-stable between store notifications. Adapters MUST NOT pass live store.get (or stopwatch.get) as getSnapshot.
The React adapter must cache the value received via subscribe (or a versioned snapshot updated only inside the listener) and have getSnapshot return that cached snapshot so it is stable between notifications. Initial / server snapshot may still come from one get() call when subscribing begins.
Adopter expectations
Adapters must:
- Read with
get()for the initial / server snapshot when needed (except React’s ongoinggetSnapshot, which must use the cached subscribe snapshot above). - Subscribe for updates; unsubscribe on teardown (
DestroyRef,onCleanup,onDestroy, elementdestroy, etc.). - Never call
start/stop/resetunless the adapter’s public API intentionally exposes those methods (hooks may return theStopwatchinstance or wrap controls).
See Agents for framework-specific binding constraints (especially Angular, Qwik, Alpine).