Skip to content

@para/signals

import { signal, derived, effect, batch, untrack, Signal } from "@para/signals";

@para/signals is a small reactive primitive. signal(v) is a cell, derived(fn) is a read-only signal computed from others, and effect(fn) runs side effects when something it read changes. Reads inside an effect register a dependency; writes invalidate downstream and a microtask flush re-runs only the effects whose observed values actually changed.

Pairs with the signal / effect { } / ~> / -> language extensions — those desugar to calls into this module. Plain .ts / .js files use the function form below; .pts / .pjs files can use either form.

Creates a writable cell.

import { signal } from "@para/signals";
const count = signal(0);
count(); // read → 0
count.get(); // read → 0
count(1); // write
count.set(2); // write
count.update(n => n + 1); // read-modify-write

count is callable: with no args it reads, with one arg it writes. The explicit .get() / .set() methods are also there for clarity. update(fn) is set(fn(get())) in one atomic-ish step. In .pts / .pjs files, signal NAME = … declarations make bare reads / writes desugar — same runtime, terser source.

A read-only signal computed from others. Tracks every signal fn reads; re-evaluates when any of them changes.

const double = derived(() => count() * 2);
double(); // 4 (after count(2))

derived is lazy — it only re-evaluates when read after an invalidation. Multiple reads between writes return the cached value. In .pts / .pjs files you can spell deriveds two ways: explicit derived NAME = expr (keyword telegraphs that the binding is read-only and is the recommended form when clarity matters), or implicit signal NAME = expr whose RHS reads another in-scope signal (auto-promoted to a derived()). Same lowering, same runtime.

Runs fn immediately, tracks its signal reads, and re-runs whenever any of them changes. Returns a disposer that removes the effect.

const dispose = effect(() => {
console.log("count is", count());
});
count(3); // logs "count is 3" on next microtask
dispose(); // stop watching

Effects fire on a microtask after the write, so multiple writes within the same synchronous code path coalesce into one re-run. The parabun effect { … } block sugar desugars to effect(() => { … }) — same runtime.

Defers effect re-runs until fn returns:

batch(() => {
count(1);
name("alice");
// no effects re-run yet
});
// effects re-run once with both new values visible

Reads inside fn don’t register as dependencies — useful inside an effect to read a signal “for context” without making the effect re-run when it changes.

effect(() => {
console.log(count(), "at", untrack(() => Date.now()));
// re-runs on count change, NOT on every Date.now read
});

Creates a signal driven by an async iterable. Saves the IIFE+for-await dance for “I want the most recent value as a Signal”.

import sigs from "@para/signals";
// Most recent value from a websocket-like source, exposed as a signal.
const { signal: msg, dispose } = sigs.fromAsync(socket.messages(), m => m.body, "");
effect(() => console.log("latest:", msg.get()));
// Clean up when you're done.
dispose();

Returns { signal, dispose }. signal is read-only (the pump owns writes); dispose breaks the loop via the iterator’s return() and fires any generator finally block. Calling dispose twice is a no-op.

If mapFn is omitted, raw yielded values flow through unchanged. If init is omitted, the signal starts at undefined.

Drive a signal from a periodic call. The IoT companion to fromAsync: takes a sync-or-async fn and a period in ms, calls it immediately and every periodMs, and exposes the latest resolved value as a Signal. Thrown errors are swallowed (the signal keeps its previous value).

The internal timer .unref()s itself, so a bare fromInterval(...) call doesn’t pin the event loop on its own — pair it with an effect { … } block or another keep-alive when you want the process to stay running on its account.

import sigs from "@para/signals";
// Poll an i2c sensor every 500 ms, expose latest reading as a signal.
const temp = sigs.fromInterval(
() => sensor.smbus.readWord(0xFA),
500,
);
sigs.effect(() => console.log("temp:", temp.signal.get()));

Same shape as fromAsync — returns { signal, dispose }. Common patterns: i2c / SPI sensor reads, periodic HTTP polls, anything where “the latest value of a periodic source” is what you want.

Drive an existing signal from an async iterable — useful when the signal pre-exists, or when you want to switch sources at runtime.

const score = sigs.signal(0);
const stop = sigs.pump(motionFrames, score, f => f.motionScore);
// later: stop();

Returns a disposer with the same semantics as fromAsync’s dispose.

The signal must be a writable one (returned by signal(...)); passing a derived(...) result throws.

Edge-detection helper. when calls fn once each time source transitions from falsy to truthy (boolean-coerced). The falling edge is the rising edge of the negated predicate, so a single helper covers both directions: when(() => !s.get(), fn) fires on true→false. The block syntaxes when not EXPR { … } and the bare-paired when stop { … } rewrite to that form automatically.

source can be either a Signal<T> (a signal(...) cell, a derived(...), or any driver-handle signal) or a predicate function — passing () => a.get() && b.get() === "x" skips the explicit derived(...) wrapper. Reads inside the predicate are tracked the same way they would be inside an effect.

The initial value is treated as already-observed — a source that starts truthy does not fire on first run; only subsequent false→true transitions do.

import signals from "@para/signals";
// Greet on the rising edge of (motion present AND bot idle).
signals.when(
() => motion.detected.get() && bot.state.get() === "idle",
() => bot.say("Welcome back!"),
);
// Or, with an existing signal:
signals.when(button.pressed, () => console.log("clicked"));
// Falling edge — negate the predicate.
signals.when(() => !button.pressed.get(), () => console.log("released"));

Returns a disposer with the same semantics as effect().

Common patterns: button-press detection, arrival/departure handlers, threshold crossings, “fire once when condition becomes true” without manual wasX = false flag bookkeeping.

Same as when but also fires once at registration if source is initially truthy. Use it for “the dangerous state is the noteworthy one” alerts where you don’t want to silently miss a boot already in the bad state. The .pts keyword form is when EXPR start { … } — the trailing start modifier is the visible opt-in for the initial-truthy fire; signals.whenStart(...) is the direct call for .ts users.

import signals from "@para/signals";
// Boot-already-empty tank? Notify now AND on every empty event.
signals.whenStart(tankEmpty, () => alert("tank EMPTY"));
// Boot with a healthy tank? `when not tankEmpty` skips the boot
// observation so you don't get a fake "back above empty" message.
signals.when(() => !tankEmpty.get(), () => log("tank refilled"));

Pick plain when (or signals.when) for “user pressed button” / “page loaded” — anywhere faking the event at startup would be wrong. Reach for start / whenStart for safety, health, and connectivity alerts where missing the boot-bad case is worse than firing one extra time.

Hardware modules emit signals tied to a real underlying resource (a mic, a camera, a file watcher, a websocket). When the resource closes, those signals should become inert and observers should unwind cleanly. resource() is the primitive that makes that lifecycle explicit:

import { resource } from "@para/signals";
const mic = resource(({ signal: sig, onDispose }) => {
const peak = sig(0);
const handle = openMic(); // pretend hardware
handle.onPeak(v => peak.set(v));
onDispose(() => handle.close());
return { peak }; // becomes mic.peak
});
mic.peak.get(); // current peak level
mic.alive.get(); // boolean signal — true until dispose
mic.use(() => console.log(mic.peak.get())); // effect bound to resource lifetime
mic.dispose(); // close mic, run cleanups, alive flips to false,
// bound effects auto-tear-down

The handle layers alive / dispose / [Symbol.dispose] / [Symbol.asyncDispose] / use(fn) on top of whatever the setup function returned. use(fn) is the key ergonomic — bound effects auto-dispose when the resource closes, so consumers don’t need defensive if (active.get()) guards everywhere.

Setup runs synchronously and may register cleanups via onDispose. Cleanups run in reverse-registration order on dispose(). If setup throws, any cleanups it had time to register are still run before the exception propagates.

Pairs with the using declaration for scope-bound resources:

{
using mic = openMicResource();
effect(() => render(mic.peak.get()));
// mic disposed automatically at scope exit
}

Hardware emits streams. These adapters lift the underlying primitive into a resource-tied signal — no manual pump loop in user code:

fromAsyncIter(iter, initial?)Pumps each yielded value into result.value. Disposing calls the iterator’s return().
fromStream(readableStream, initial?)Same for ReadableStream<T>. Cancels the reader on dispose.
fromEventTarget(target, eventName, { initial?, map? })Listens for events; signal updates with map(event). Removes the listener on dispose.
const live = fromStream(audioFrames, null);
effect(() => process(live.value.get()));
live.dispose(); // reader cancelled, stream gracefully released

Each returns a resource handle, so .alive, .dispose(), .use() are available the same way.

Hardware emits faster than UI consumers want — a mic’s peak level updates 1000 Hz; a UI wants 30 fps. These operators wrap a source signal and emit at controlled cadence:

throttled(source, ms)Leading-edge: first change emits immediately; subsequent changes within ms coalesce into a trailing emit at window end.
debounced(source, ms)Emits only after ms of silence following the last change.

Both return resources whose result.value is the rate-limited signal:

const peakSlow = throttled(mic.peak, 33); // 30fps view of a 1000hz source
effect(() => render(peakSlow.value.get()));
peakSlow.dispose();

Exported for type annotations. signal(0) returns a Signal<number>; derived(...) returns a Signal<T> (read-only — TypeScript marks .set / .update as never).

  • DOM-ish updates: pair with ~> (reactive assignment) to keep DOM elements / canvas state in step with signal values, or -> (reactive call-binding) to push each new value into a sink function (process.stdout.write, socket.send, …).
  • Background work: an effect can dispatch a @para/parallel pmap and write the result back into a signal — the next read picks it up.
  • Server-rendered fragments: derived(() => render(...)) recomputes only when inputs change.
  • IoT control loops: fromInterval for any periodic source (parabun:i2c sensors, HTTP polls), pollHz on parabun:gpio lines for hardware-backed value signals.
  • Effects are async (microtask-flushed). For synchronous “see the new value right now” you need batch(...) and a synchronous read.
  • Cycle detection is best-effort — a derived(() => sigA()) where sigA is itself a derived of the first will throw at registration time, but more elaborate cycles can stack-overflow on flush.
  • No ownership / scope — effects live forever unless dispose()d. Wrap with @para/arena-style scoping in long-lived loops.