@zakkster/lite-watch-ex
v1.1.0
Published
Extended watcher utilities for @zakkster/lite-signal: one-shot, predicate-gated, rolling-history, pausable, and multi-source watchers, plus Vue-style watchEffect. Zero-GC hot path. Re-exports the core watch/when/whenAsync.
Maintainers
Readme
@zakkster/lite-watch-ex
Extended watcher utilities for
@zakkster/lite-signal: one-shot watchers, predicate-gated watchers, Vue-stylewatchEffect. Zero-GC hot path. Re-exports the corewatch,when,whenAsyncfor convenience.
npm install @zakkster/lite-watch-ex @zakkster/lite-signalimport { signal } from "@zakkster/lite-signal";
import { watchOnce, watchUntil, watchEffect } from "@zakkster/lite-watch-ex";
const status = signal("loading");
// Fire once on the first non-equal change, then self-dispose.
watchOnce(status, (next) => initialize(next));
// Fire when a predicate first passes on a changed value, then self-dispose.
watchUntil(
() => player.health(),
(h) => h <= 25,
(h) => showLowHealthWarning(h)
);
// effect() with onCleanup pre-injected (Vue-style).
watchEffect((onCleanup) => {
const id = requestAnimationFrame(render);
onCleanup(() => cancelAnimationFrame(id));
});
status.set("ready"); // watchOnce fires once, then disposesOne-shot watchers self-dispose synchronously when they fire -- the effect node returns to lite-signal's pool, not just silenced by a flag. Per source change in steady state, zero JS-heap allocations from this file.
Contents
- Why this exists
- What you get
- API reference
- Examples
- When to use which
- Semantics worth knowing
- Allocation profile
- Testing
- License
Why this exists
@zakkster/lite-signal ships watch, when, and whenAsync for general-purpose reactive observation. They're complete for ongoing watchers and one-shot truthiness gates, but two common patterns fall outside their surface:
- One-shot watcher with old/new pair.
whendoesn't pass(next, prev)-- it's "fire callback with no args when truthy".watchOnceiswatchthat auto-disposes after the first non-equal change. - Predicate-gated watcher with old/new pair.
whenprojects through truthiness;watchUntillets the predicate inspect the projected value and passes both old and new to the callback. - Vue ergonomics.
watchEffect((onCleanup) => { ... })is a familiar shape for users coming from Vue 3. Plaineffect()works identically with a manualonCleanupimport, but the wrapper saves the import and reads cleanly inline.
All three are thin layers on top of effect + untrack + onCleanup. They follow the same zero-GC contract as the rest of the lite-* stack: setup-time closures only, no per-fire allocation in steady state, dispose returns the effect node to the pool.
What you get
watchOnce(source, callback, options?)-- fire once on the first non-equal change (or immediately, ifoptions.immediate === true), then self-dispose.watchUntil(source, predicate, callback)-- fire once when the predicate first passes on a changed value, then self-dispose.watchEffect(callback)--effect()withonCleanuppre-injected as a callback argument. Identical alloc profile to plaineffect().watchPrevious(source, callback, options?)-- rolling history of the lastdepthvalues (default 1), most-recent-first, in a reused buffer. Velocity, smoothing, deltas.watchChanged(source, predicate, callback, options?)-- ongoing watcher gated by a(new, old)predicate. Fires on every passing change.pausableWatch(source, callback, options?)-- pausable ongoing watcher; returns{ stop, pause, resume, isPaused }. Optional one-shot catch-up on resume.watchMany(sources, callback, options?)-- watch a fixed tuple of sources; fires with(values, oldValues, changedIndex, stop).
Plus the canonical core utilities re-exported for convenience (importing from either package resolves to the same function):
watch(source, callback, options?)-- ongoing watcher with(next, prev, stop).when(predicate, callback)-- fire once when predicate first truthy. No old/new pair.whenAsync(predicate)-- Promise-returning variant ofwhen. (!) allocates a Promise per call -- see lite-signal's README for the hot-path warning.
All three one-shot watchers return a stop() function. It's idempotent, safe to call before the watcher has fired, and a no-op after self-dispose.
API reference
watchOnce(source, callback, options?)
function watchOnce<T>(
source: () => T,
callback: (newValue: T, oldValue: T | undefined) => void,
options?: { immediate?: boolean }
): () => void;Watch a reactive source, fire callback exactly once on the first non-equal change, then self-dispose. With { immediate: true }, fires synchronously at registration with oldValue = undefined and disposes immediately.
Equality is Object.is. NaN-to-NaN is a no-op. Internal callback reads are untracked: side-effects don't accidentally re-track the source.
watchUntil(source, predicate, callback)
function watchUntil<T>(
source: () => T,
predicate: (value: T) => unknown,
callback: (newValue: T, oldValue: T | undefined) => void
): () => void;Watch a reactive source until predicate(newValue) first returns truthy. On the initial run, fires immediately if the predicate is already truthy (with oldValue = undefined). On subsequent runs, fires only when both the value changes AND the predicate passes. Self-disposes on fire.
Distinct from when: watchUntil passes the projected value plus its previous value to the callback. when is "wait for truthy, fire callback with no arguments" -- use it when you don't need the value.
watchEffect(callback)
function watchEffect(
callback: (onCleanup: (fn: () => void) => void) => void
): () => void;effect() with onCleanup pre-injected as a callback argument. Matches the Vue watchEffect ergonomic.
Functionally identical to:
import { effect, onCleanup } from "@zakkster/lite-signal";
const stop = effect(() => {
onCleanup(() => releaseSomething());
// ... reactive body ...
});The wrapper exists for API surface only. No per-fire allocation cost.
Examples
Wait for first state transition, then initialize:
import { signal } from "@zakkster/lite-signal";
import { watchOnce } from "@zakkster/lite-watch-ex";
const status = signal("loading");
watchOnce(status, (next) => {
if (next === "ready") bootstrap();
});
status.set("ready"); // fires; watcher disposes
status.set("idle"); // inertLow-health alert that fires once and stops:
import { watchUntil } from "@zakkster/lite-watch-ex";
watchUntil(
() => player.health(),
(h) => h <= 25,
(h) => hud.showWarning(`Health critical: ${h}`)
);rAF-cancelled cleanup with watchEffect:
import { signal } from "@zakkster/lite-signal";
import { watchEffect } from "@zakkster/lite-watch-ex";
const cursor = signal({ x: 0, y: 0 });
const stop = watchEffect((onCleanup) => {
const { x, y } = cursor();
const rafId = requestAnimationFrame(() => renderCursor(x, y));
onCleanup(() => cancelAnimationFrame(rafId));
});Immediate-mode watchOnce -- fire-and-forget initialization tied to a signal:
import { watchOnce } from "@zakkster/lite-watch-ex";
watchOnce(currentUser, (user) => greet(user), { immediate: true });
// Fires synchronously with the current value, then disposes.When to use which
| You want | Use |
| ----------------------------------------------------- | -------------------------------- |
| Ongoing watcher with (next, prev, stop) | watch (from lite-signal) |
| Fire once on first non-equal change | watchOnce(src, cb) |
| Fire once on first non-equal change + immediate run | watchOnce(src, cb, { immediate: true }) |
| Fire once when a predicate passes on a changed value | watchUntil(src, pred, cb) |
| Fire once when a predicate first truthy, no old/new | when(pred, cb) |
| Same, but await as a Promise (rare paths only) | whenAsync(pred) -- see warning |
| Effect with onCleanup as callback arg | watchEffect((onCleanup) => ...) |
| Effect without it | effect(() => ...) (lite-signal) |
The one-shot watchers are not just convenience over watch + a manual stop() call -- they release the effect node to the pool synchronously on fire. A watch that the user disposes manually does the same thing, but the bookkeeping is one line in each callback. watchOnce and watchUntil make the contract explicit and uncrossable.
Semantics worth knowing
- Self-dispose is real. When a one-shot fires,
stop()is called from inside the effect body. lite-signal's dispose is idempotent and safe to call from within the executing effect (Signal.js:746-784); the node is destroyed and returns to the pool. Subsequent source changes don't reach the watcher -- there's nothing left to reach. stop()is idempotent and late-binding. Safe to call before the watcher fires (cancels pre-emptively), safe to call from inside an immediate-mode callback on the very first run (deferred untileffect()returns), safe to call multiple times.Object.isfor equality.NaN-to-NaNis a no-op;+0/-0distinct. Matches the rest of the lite-signal contract.- Callback internal reads are untracked. A
watchOncecallback that reads other signals to perform side-effects won't accidentally subscribe -- the watcher only re-fires on the originalsource's deps. Identical to corewatch. watchEffect'sonCleanupis the same as the standalone one. It's the context-aware function imported from lite-signal, pre-passed for ergonomic convenience. Both work; pick the one that reads better at the call site.watchUntilinitial check is synchronous. If the predicate is already truthy when you register, the callback fires synchronously inside the registration call witholdValue = undefined. Important if you rely on dispose timing for setup ordering.
Allocation profile
| Op | JS-heap allocations from this file | Notes |
| --------------------------------- | ----------------------------------- | ----- |
| watchOnce() registration | 1 effect node, links + 3 closures | One-time, from the pool. The hoisted untrackedFire body is allocated once and reused. |
| watchUntil() registration | 1 effect node, links + 3 closures | Same shape as watchOnce. |
| watchEffect() registration | 1 effect node, links + 1 closure | Just the effect body. onCleanup is the imported function reference, not allocated. |
| Per source change (steady state) | 0 | The untrack(untrackedFire) call reuses a single hoisted closure. |
| Per self-dispose on fire | 0 | Releases the node back to lite-signal's pool. |
| stop() called externally | 0 | Idempotent flag flip + node release. |
The "ZERO-GC HOT PATH" comment in lite-signal's Watch.js exists to warn against declaring untrack(() => { ... }) inline inside the effect body -- V8 allocates a fresh closure per fire. This package follows the hoisted-closure pattern: shared state via closure-scoped variables (currentNewValue, oldValue, fired), single reused untrackedFire reference.
Testing
npm test # behavior suite (node:test)
npm run test:gc # adds --expose-gc; the steady-state heap-delta guard engagesThe GC-required test skips silently when globalThis.gc is absent, so npm test is clean without --expose-gc; npm run test:gc engages it. Suite covers:
- First-change fire and self-dispose, both modes
- Immediate-mode synchronous fire with early
stop() Object.isshort-circuit (includingNaN)- Predicate-already-truthy initial fire
- External dispose before fire
- Re-entrant source write from inside the callback
onCleanupregistration and re-run cleanup inwatchEffect- Rolling history windows (
watchPrevious): depth 1 and 3, reused-buffer identity,copy,NaNno-op - Predicate-gated ongoing delivery (
watchChanged), immediate predicate, callback self-stop - Pause/resume gating and single catch-up on resume (
pausableWatch) - Multi-source fire with
changedIndex, lowest-index under batch,copy(watchMany) - Steady-state heap-delta guard (
--expose-gc)
Added in 1.1.0
Four new watchers, same zero-GC contract (setup-time closures only, no per-fire allocation in steady state).
watchPrevious(source, callback, options?)
function watchPrevious<T>(
source: () => T,
callback: (current: T, history: Array<T | undefined>, stop: () => void) => void,
options?: { depth?: number; immediate?: boolean; copy?: boolean }
): () => void;A rolling-history watcher. The callback gets (current, history, stop), where
history holds the previous depth values, most-recent-first (history[0] is
the immediately previous value). At depth: 1 (default) it is a plain old-value
buffer; at depth > 1 it is a fixed window for velocity, smoothing, or delta
work. The history array is allocated once and reused per fire (an in-place
shift, no allocation); pass copy: true to receive a fresh snapshot. History
length is always depth -- not-yet-filled slots are undefined, and the
current value is never included.
// Delta over the last value
watchPrevious(score, (now, [prev]) => {
if (prev !== undefined) hud.showDelta(now - prev);
});
// Velocity over a 3-sample window (newest-first history)
watchPrevious(() => pointer.x(), (x, recent) => {
if (recent[1] !== undefined) velocity = (x - recent[1]) / 2;
}, { depth: 3 });watchChanged(source, predicate, callback, options?)
function watchChanged<T>(
source: () => T,
predicate: (newValue: T, oldValue: T | undefined) => unknown,
callback: (newValue: T, oldValue: T | undefined, stop: () => void) => void,
options?: { immediate?: boolean }
): () => void;An ongoing watcher gated by a predicate over (new, old). Fires on every change
that passes -- unlike watch (fires on all changes) and watchUntil (fires
once, then disposes). Use it for deltas, thresholds, or epsilon gates without an
if at the top of every callback.
// Only when a value increases
watchChanged(level, (now, prev) => prev === undefined || now > prev,
(now) => playLevelUp(now));pausableWatch(source, callback, options?)
function pausableWatch<T>(
source: () => T,
callback: (newValue: T, oldValue: T | undefined, stop: () => void) => void,
options?: { immediate?: boolean; fireOnResume?: boolean }
): { stop: () => void; pause: () => void; resume: () => void; readonly isPaused: boolean };An ongoing watcher you can pause and resume. Pausing suppresses the callback but
keeps the watcher alive -- pause/resume cycles cost zero reallocation, and the
value baseline is preserved. With fireOnResume: true, a single catch-up fire
delivers (current, valueAtPause) on resume if the value changed while paused.
const w = pausableWatch(cursor, (p) => render(p));
openModal(); w.pause(); // freeze live rendering
closeModal(); w.resume(); // back to live
w.stop(); // tear downwatchMany(sources, callback, options?)
function watchMany<T>(
sources: Array<() => T>,
callback: (values: T[], oldValues: T[], changedIndex: number, stop: () => void) => void,
options?: { immediate?: boolean; copy?: boolean }
): () => void;Watch a fixed tuple of sources; fire when any changes. The callback gets
(values, oldValues, changedIndex, stop). changedIndex is the source that
triggered the fire (the lowest index if several changed in one batch; -1 on
the initial immediate fire) -- an O(1) "which one" without diffing the arrays
yourself. values/oldValues are reused buffers (do not retain across fires);
pass copy: true for fresh arrays.
watchMany([() => width(), () => height()], (vals, _old, i) => {
layout(vals[0], vals[1]);
if (i === 0) console.log("width drove this relayout");
});Allocation. All four allocate setup-time closures (and, for watchPrevious
and watchMany, one or two reused buffers) once at registration. Per change in
steady state: zero JS-heap allocations, unless copy: true is set (then one
array per fire for watchPrevious, two for watchMany).
When to reach for them. watchPrevious for deltas, velocity, or smoothing;
watchChanged for conditional ongoing observation; pausableWatch for
observation you need to gate (modals, tab visibility, drag sessions);
watchMany for derived work that depends on several sources at once.
License
MIT (c) Zahary Shinikchiev
Part of the @zakkster zero-GC stack:
lite-signal-lite-debounce-lite-throttle-lite-ecs-lite-ease-lite-pointer-tracker-lite-bmfont-lite-color
