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. A parabun signal NAME = expr whose RHS reads another in-scope signal is automatically promoted to a read-only derived().

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.

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.

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 (para:i2c sensors, HTTP polls), pollHz on para: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.