@moq/signals
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.
| Class | What it is |
|---|---|
Signal<T> | A mutable observable value: peek(), set(), update(), subscribe(), changed(). |
Computed<T> | A read-only value derived from other signals. |
Effect | A 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. |
Derived | A cheap mapped view with no lifecycle. |
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(); // permanentThe 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, andrun(a nested effect) all clean up on rerun or close, so never callsetTimeoutoraddEventListenerinside one directly. A rerun waits for the previous run'sspawntasks to settle, andeffect.abort/effect.racetell them to stop. - Teardown runs last-in, first-out, like
DisposableStack: whatever registered later (a nestedrunusing an earlier resource) is released before what it depends on. - Race with
race, notPromise.race.Promise.raceleaves a listener on every value that loses, so racing a long-lived one (aclosed, a run's teardown) once per frame grows the heap.race([...])accepts promises andOncevalues and drops its listeners when it settles;effect.race(promise)also resolvesundefinedonce 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.
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)} />;
}