@azmr/core
Reactive engine — signals, effects, and computed values.
Installation
pnpm add @azmr/coreSignal
A reactive value container. Any effect that reads .get() while active will re-run when the value changes.
import { Signal } from "@azmr/core";
const count = new Signal(0);
count.set(1);
console.log(count.get()); // 1
console.log(count.peek()); // 1 — reads without subscribingCustom equality
set() uses Object.is by default, so setting an object/array to a new reference always notifies subscribers, even if its shape is unchanged. Pass an equals option to compare by shape instead:
const shallowEqual = (a: { x: number }, b: { x: number }) => a.x === b.x;
const point = new Signal({ x: 1 }, { equals: shallowEqual });
point.set({ x: 1 }); // no-op — equals() says these are the sameeffect
Runs a function immediately and re-runs whenever any Signal read inside it changes. Returns a disposer that detaches the effect from every signal it read — safe to call more than once, and safe to call from inside another effect during the same flush.
import { Signal, effect } from "@azmr/core";
const name = new Signal("Aroha");
const dispose = effect(() => {
console.log(`Hello, ${name.get()}`);
});
// → Hello, Aroha
name.set("Tane");
// → Hello, Tane
dispose();
name.set("Mere"); // no longer logged — the effect is detachedA conditional read (cond.get() ? a.get() : b.get()) re-evaluates its dependencies on every
re-run, so switching branches drops the subscription to the signal in the untaken branch.
Cleanup
The callback may return a cleanup function. It runs right before the next re-run (after stale
dependencies are dropped, before the callback runs again) and on dispose — the same shape as
React's useEffect.
effect(() => {
const id = setInterval(() => console.log(count.get()), 1000);
return () => clearInterval(id);
});computed
A read-only Signal whose value is derived from other Signals. The returned Signal carries a dispose() for tearing down the derived chain.
import { Signal, computed } from "@azmr/core";
const price = new Signal(100);
const gst = computed(() => price.get() * 0.15);
console.log(gst.get()); // 15
price.set(200);
console.log(gst.get()); // 30
gst.dispose();
price.set(9999);
console.log(gst.peek()); // 30 — frozen at its last computed valueLazy evaluation
fn doesn't run at creation. While the computed is unobserved — nothing reading it via effect(), another computed(), or subscribe() — an upstream change only marks it stale; the actual recomputation is deferred to the next .get()/.peek(), and always uses the latest values, not whatever was current when the signal changed. Once something depends on it, it switches to recomputing eagerly on every upstream change, matching the old always-eager behaviour, so an observing effect still only re-runs when the derived value itself actually changes.
const price = new Signal(100);
const gst = computed(() => price.get() * 0.15); // fn hasn't run yet
price.set(200); // gst is unobserved — just marked stale, not recomputed
price.set(300);
gst.get(); // 45 — computes once here, from the latest valueBehaviour change: a throwing fn used to throw synchronously from computed(fn) itself. It now
throws from the first .get()/.peek() instead, since that's when fn actually runs.
subscribe
Listen to a Signal from outside the reactive system — useful for bridging to React, Vue, or any non-reactive code.
import { Signal } from "@azmr/core";
const price = new Signal(100);
const unsubscribe = price.subscribe((value) => {
console.log(`price changed to ${value}`);
});
price.set(200); // → price changed to 200
unsubscribe(); // stop listeningbatch
Coalesces every set() call made inside a callback into a single effect flush, instead of one flush per set(). Values update synchronously as usual — .get()/.peek() inside the callback always see the latest write; only the effect flush is deferred. Nests correctly: an inner batch() completing does not trigger a flush while an outer one is still open.
import { Signal, batch, effect } from "@azmr/core";
const first = new Signal("Aroha");
const last = new Signal("Ngata");
effect(() => console.log(`${first.get()} ${last.get()}`));
// → Aroha Ngata
batch(() => {
first.set("Tane");
last.set("Mahuta");
});
// → Tane Mahuta (logged once, not twice)untrack
Runs a callback with dependency tracking suspended — any .get() calls made inside it (including ones buried in code you call into, not just a direct call at the top level) don't register a dependency on the currently-running effect. Unlike .peek(), which only works where you control the call site, untrack() composes through indirection — it works even when the callback calls third-party or generic code that uses .get() internally. A no-op outside an effect. An effect created inside the callback still tracks its own reads normally.
import { Signal, effect, untrack } from "@azmr/core";
const count = new Signal(0);
const debugFlag = new Signal(false);
effect(() => {
// Reacts to count, but reading debugFlag here should never itself
// trigger a re-run — it's just checked, not depended on.
if (untrack(() => debugFlag.get())) console.log(`count: ${count.get()}`);
});onError
Registers a handler for errors thrown by an effect() re-run or a subscribe() callback during a flush. Without this, one bad subscriber's exception propagates out of an unrelated caller's set() and aborts every other subscriber still pending in that flush; with a handler registered, the error is routed here instead and the flush continues. Returns a function that restores whichever handler was active before the call. Doesn't apply to effect(fn)'s first, synchronous run, or to a direct fn() call inside batch()/untrack() — those still throw normally to the caller, who is right there to catch them.
import { Signal, effect, onError } from "@azmr/core";
const restore = onError((error) => {
console.error("effect failed:", error);
});
const count = new Signal(0);
effect(() => {
if (count.get() > 10) throw new Error("too high");
});
restore(); // back to the previous handler (none by default)If no handler is registered, the error is rethrown asynchronously (via a resolved Promise) so it isn't silently swallowed, without blocking the flush that surfaced it.
Safety
The scheduler uses a generation counter to prevent effects from running more than once per flush cycle. An effect that reads and writes the same signal will run at most twice — the re-queue is deduplicated, preventing unbounded loops.
const s = new Signal(0);
// Safe — deduplicated by the scheduler, never runs unboundedly
effect(() => {
s.get();
s.set(s.peek() + 1);
});API Reference
Signal<T>
Constructor: new Signal(initialValue, options?). options.equals replaces the default Object.is check set() uses to decide whether a write is a no-op.
| Method | Returns | Description |
|---|---|---|
get() | T | Read value and subscribe current effect |
set(value) | void | Update value and notify subscribers |
peek() | T | Read value without subscribing |
subscribe(fn) | () => void | Register a callback, returns unsubscribe function |
Functions
| Function | Description |
|---|---|
effect(fn) | Run fn reactively, returns a disposer that fully detaches it |
computed(fn) | Create a derived read-only Signal, computed lazily; return value has a dispose() |
batch(fn) | Coalesce set() calls inside fn into one flush; returns fn's return value |
untrack(fn) | Run fn with dependency tracking suspended; returns fn's return value |
onError(handler) | Register a handler for errors thrown during a flush; returns a function that restores the previous handler |