npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

Readme

@zakkster/lite-watch-ex

Extended watcher utilities for @zakkster/lite-signal: one-shot watchers, predicate-gated watchers, Vue-style watchEffect. Zero-GC hot path. Re-exports the core watch, when, whenAsync for convenience.

npm version Zero-GC sponsor npm bundle size npm downloads npm total downloads TypeScript lite-signal peer Dependencies license

npm install @zakkster/lite-watch-ex @zakkster/lite-signal
import { 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 disposes

One-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

@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. when doesn't pass (next, prev) -- it's "fire callback with no args when truthy". watchOnce is watch that auto-disposes after the first non-equal change.
  • Predicate-gated watcher with old/new pair. when projects through truthiness; watchUntil lets 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. Plain effect() works identically with a manual onCleanup import, 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, if options.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() with onCleanup pre-injected as a callback argument. Identical alloc profile to plain effect().

  • watchPrevious(source, callback, options?) -- rolling history of the last depth values (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 of when. (!) 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");    // inert

Low-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 until effect() returns), safe to call multiple times.
  • Object.is for equality. NaN-to-NaN is a no-op; +0/-0 distinct. Matches the rest of the lite-signal contract.
  • Callback internal reads are untracked. A watchOnce callback that reads other signals to perform side-effects won't accidentally subscribe -- the watcher only re-fires on the original source's deps. Identical to core watch.
  • watchEffect's onCleanup is 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.
  • watchUntil initial check is synchronous. If the predicate is already truthy when you register, the callback fires synchronously inside the registration call with oldValue = 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 engages

The 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.is short-circuit (including NaN)
  • Predicate-already-truthy initial fire
  • External dispose before fire
  • Re-entrant source write from inside the callback
  • onCleanup registration and re-run cleanup in watchEffect
  • Rolling history windows (watchPrevious): depth 1 and 3, reused-buffer identity, copy, NaN no-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 down

watchMany(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