MoQ is under active development. APIs will change, but we keep backwards wire compatibility.

Skip to content

@moq/signals ​

npm

Reactive signals with explicit tracking and automatic cleanup. Every @moq package exposes its state through these, so this is how you drive and observe the rest.

ClassWhat it is
Signal<T>A mutable observable value: peek(), set(), update(), subscribe(), changed().
Computed<T>A read-only value derived from other signals.
EffectA scope that reruns when a signal it read changes, and tears down what it set up.
Once<T>Terminal state that settles once, observable and awaitable. Used for closed.
DerivedA cheap mapped view with no lifecycle.
ts
import { Effect, Signal } from "@moq/signals";

const volume = new Signal(1);

const effect = new Effect((effect) => {
    const v = effect.get(volume);            // read AND subscribe; peek() reads without subscribing
    effect.interval(() => console.log(v), 1000);  // cancelled on rerun or close
    effect.cleanup(() => console.log("bye"));
});

volume.set(0.5);   // reruns
effect.close();    // permanent

The rules that differ from other signal libraries:

  • Nothing is tracked implicitly. effect.get(signal) subscribes; signal.peek() doesn't.
  • Writes coalesce per microtask and only notify on a real change (deep for plain objects, identity for class instances).
  • Effects own their resources. effect.timer, interval, animate, event, spawn, and run (a nested effect) all clean up on rerun or close, so never call setTimeout or addEventListener inside one directly. A rerun waits for the previous run's spawn tasks to settle, and effect.abort/effect.race tell them to stop.
  • Teardown runs last-in, first-out, like DisposableStack: whatever registered later (a nested run using an earlier resource) is released before what it depends on.
  • Race with race, not Promise.race. Promise.race leaves a listener on every value that loses, so racing a long-lived one (a closed, a run's teardown) once per frame grows the heap. race([...]) accepts promises and Once values and drops its listeners when it settles; effect.race(promise) also resolves undefined once the run is torn down.
  • Misuse warns in every build, with the effect's creation stack: effects that tracked nothing, effects garbage-collected without close(), and calls on a closed effect.

Signal.race(a, b) returns a lazy GetPromise for the next signal change. It attaches listeners when awaited or subscribed and releases them when it settles or its last subscriber detaches. Use race([Signal.race(a, b), closed]) or effect.race(Signal.race(a, b)) to release the signal listeners when another value wins. Direct await Signal.race(a, b) still works. To start waiting now and await later, call race([Signal.race(a, b)]); merely creating the handle does not observe changes. The handle supports .then(), but is not a native Promise and has no .catch() or .finally().

Components follow one shape: in (wired inputs), out (read-only derived state), and public writable knobs. getter() and Inputs<T> accept a raw value, a signal, another component's output, or any other Getter interchangeably.

Adapters: @moq/signals/react (useValue, useSignal), @moq/signals/solid (createAccessor, createPair), and @moq/signals/dom for building reactive DOM without a framework, which is what the UI overlays use.

tsx
import { useValue } from "@moq/signals/react";

function Volume({ watch }) {
    const volume = useValue(watch.controls.volume);
    return <input type="range" value={volume} onChange={(e) => watch.controls.volume.set(+e.target.value)} />;
}

Licensed under MIT or Apache-2.0