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.
signal(initial)
Section titled “signal(initial)”Creates a writable cell.
import { signal } from "para:signals";
const count = signal(0);
count(); // read → 0count.get(); // read → 0count(1); // writecount.set(2); // writecount.update(n => n + 1); // read-modify-writesignal count = 0;
count; // read → 0 (bare reads desugar to .get())count = 1; // write (= desugars to .set())count++; // read-modify-writecount 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.
derived(fn)
Section titled “derived(fn)”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))signal double = count * 2; // RHS reads `count` → auto-deriveddouble; // 4 (after count = 2)derived is lazy — it only re-evaluates when read after an invalidation. Multiple reads between writes return the cached value. A parabun signal NAME = expr whose RHS reads another in-scope signal is automatically promoted to a read-only derived().
effect(fn)
Section titled “effect(fn)”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 microtaskdispose(); // stop watchingeffect { console.log("count is", count);}
count = 3; // logs on next microtaskEffects 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.
batch(fn)
Section titled “batch(fn)”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 visibleuntrack(fn)
Section titled “untrack(fn)”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});fromAsync(iterable, mapFn?, init?)
Section titled “fromAsync(iterable, mapFn?, init?)”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.
fromInterval(fn, periodMs)
Section titled “fromInterval(fn, periodMs)”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()));import sigs from "para:signals";
const temp = sigs.fromInterval( () => sensor.smbus.readWord(0xFA), 500,);
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.
pump(iterable, signal, mapFn?)
Section titled “pump(iterable, signal, mapFn?)”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.
onRising(source, fn) / onFalling(source, fn)
Section titled “onRising(source, fn) / onFalling(source, fn)”Edge-detection helpers. onRising calls fn once each time source transitions from falsy to truthy (boolean-coerced); onFalling is the dual.
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 onRising on first run; only subsequent false→true transitions do.
import { onRising } from "para:signals";
// Greet on the rising edge of (motion present AND bot idle).onRising( () => motion.detected.get() && bot.state.get() === "idle", () => bot.say("Welcome back!"),);
// Or, with an existing signal:onRising(button.pressed, () => console.log("clicked"));Both forms return 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.
Signal<T> type
Section titled “Signal<T> type”Exported for type annotations. signal(0) returns a Signal<number>; derived(...) returns a Signal<T> (read-only — TypeScript marks .set / .update as never).
Composing with the rest of the stack
Section titled “Composing with the rest of the stack”- 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
effectcan dispatch apara:parallelpmapand 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:
fromIntervalfor any periodic source (para:i2csensors, HTTP polls),pollHzonpara:gpiolines for hardware-backed value signals.
Limits
Section titled “Limits”- 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())wheresigAis itself aderivedof 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 withpara:arena-style scoping in long-lived loops.