An extremely simple reactive programming library implementing signals. Written in TypeScript, zero dependencies, ships with type definitions.
3.0.0 is currently in beta (npm install compute@beta). Issues and feedback welcome.
import { signal, computed, effect } from "compute";
const price = signal(10);
const quantity = signal(2);
const total = computed(() => price() * quantity());
effect(() => console.log(`Total: ${total()}`));
// logs "Total: 20"
price(15);
quantity(3);
// logs "Total: 45" — once, on the next microtask
A signal is a value that knows who depends on it. Reading a signal inside a computed value or an effect registers a dependency, so when the signal changes, everything derived from it is brought up to date automatically — you never wire up (or forget to remove) a listener by hand.
In compute, a signal is represented as a callable function: call it with no arguments to read, call it with a value to write.
const count = signal(42);
count(); // 42 — and if called inside computed()/effect(), registers a dependency
count(43); // write; everything depending on count is now stale
There are two kinds:
signal(initialValue).computed(fn) — their value comes from other signals and cannot be written directly.Changes are detected with strict equality: setting the same value again does nothing.
If you've met this concept as signals in modern frameworks or the TC39 Signals proposal — same concept; this library follows the proposal's semantics. (Earlier versions of this library called them observables, a name this concept went by for years — not to be confused with RxJS Observables, which are streams.)
npm install compute@beta
import { signal, computed, effect, untrack } from "compute";
TypeScript definitions are bundled — no @types package needed. The library is published as an ES module.
computed(): derive valuescomputed defines a signal calculated from other signals. You don't declare dependencies — whatever signals the function reads are tracked automatically.
const a = signal(1);
const b = signal(2);
const sum = computed(() => a() + b());
sum(); // 3
a(10);
sum(); // 12
effect(): react to changeseffect runs a function immediately, tracks the signals it reads, and re-runs it when any of them change — however many layers of computed sit in between.
const price = signal(10);
const quantity = signal(2);
const total = computed(() => price() * quantity());
const subscription = effect(() => {
console.log(`Total: ${total()}`);
});
// logs "Total: 20" immediately
price(15);
quantity(3);
// logs "Total: 45" — once, on the next microtask
subscription.unsubscribe(); // stop reacting
By default effects are batched: all writes in the same tick coalesce into a single re-run that observes the final state. If you need to react synchronously at each write, pass { sync: true }:
const price = signal(10);
effect(() => console.log(price()), { sync: true }); // logs 10 immediately
price(20); // logs 20 immediately, during the write
untrack(): read without dependingInside a computed or effect, wrap a read in untrack to use a value without subscribing to it:
const label = signal("count");
const count = signal(0);
effect(() => {
console.log(`${untrack(() => label())}: ${count()}`);
});
// re-runs when count changes; changes to label are ignored
subscribe(): low-level notificationsEvery signal — including computeds — has a subscribe method for synchronous, per-change notification with old and new values:
const count = signal(1);
const sub = count.subscribe((newValue, oldValue) => {
console.log(`${oldValue} -> ${newValue}`);
});
count(2); // logs "1 -> 2"
sub.unsubscribe();
Prefer effect for application logic; subscribe is the low-level primitive.
These are the library's contracts:
!==-equal value is not a change: nothing downstream is notified or re-evaluated.computed(() => flag() ? a() : b()) only depends on b while flag() is false — changes to a don't even mark it stale.TypeError; async functions are not supported as computeds. Reacting to changes — including writing other signals — is what effect is for.unsubscribe(). Avoid creating effects inside other effects or computeds. An effect that writes to its own dependencies re-runs until stable, and is disposed with an error if it never stabilizes.TypeError at runtime and is a compile error in TypeScript.| 2.x | 3.x |
|---|---|
observable(v) |
signal(v) |
isObservable(v) |
isSignal(v) |
from(fn, a, b) |
computed(() => fn(a(), b())) — dependencies are now tracked automatically |
someComputed.unsubscribe() |
not needed — unobserved computeds are garbage-collected |
onChange(fn, a, b) |
effect(() => fn(a(), b()), { sync: true }) — note effects also run once at creation |
gather(a, b) |
[a(), b()] |
writing to a from observable |
throws; write to its sources |
Observable<T> / ReadonlyObservable<T> (types) |
Signal<T> / ReadonlySignal<T> |
You can see the API at https://akshat1.github.io/compute/