native-signal
v0.6.1
Published
Similar to Angular's Signal system, but framework independent, fast, and with some planned collection support.
Readme
native-signal & native-signal-ui
A tiny, framework-independent reactivity core (native-signal) that works in servers and browsers alike.
NativeSignal, Computed, Effect, plus reactive collections and helpers.
See native-signal-ui for a reactive jsx framework which implements these signals.
Table of contents
The one rule you must know (GC / own)
native-signal's default ("weak") build holds every subscriber and dependant through a
WeakRef. You must keep a strong reference to anything you want to keep firing.
new NativeSignal(1).subscribe(fn); // fn is held weakly!If nothing else references fn, the garbage collector will eventually collect it and the
subscription silently stops. The same applies to Computeds and Effects: a Computed
nobody holds, or an Effect that has gone out of scope, will stop working once GC runs.
The upside: no orphaned listeners, ever. The downside: you own lifetimes explicitly by not discarding the relevant object's references.
There is also a strong build (native-signal/strong) where nothing is GC'd
automatically — you must destroy() every Computed/Effect yourself. The weak build is
the default and what the rest of this document assumes.
Part 1 — native-signal
The reactive core. Three primitives — NativeSignal, Computed, Effect — built on a
shared Subscribable. Together they form a push-based graph with automatic dependency
tracking: any signal.get() called while a Computed runs becomes a dependency of that
computed.
Install & import
pnpm add native-signalEverything is imported from the weak entry point:
import { NativeSignal, Computed, Effect, detached } from "native-signal/weak";The package also exposes
native-signal/strong(manual lifetimes). Pick one and stick with it across your app.
NativeSignal<T>
A writable, stateful value — the root of any reactive graph.
const count = new NativeSignal(0);
count.get(); // → 0; registers a dependency if read inside a Computed
count.set(1); // set a value
count.update(v => v + 1); // shorthand for set(fn(current))
count._value; // raw read — bypasses dependency tracking
count.dirty(count); // force-mark dirty without changing the value (e.g. after
// mutating a collection/object in place)Key behaviors:
set()is a no-op if the new value===the current one — no dirty propagation, no emission.- Emissions coalesce.
set()marks dependants dirty synchronously, but value subscribers are notified asynchronously on the next microtask. A thousandset()s in one tick collapse into a single emission. ReadonlySignal<T>isNativeSignal<T>withoutset— hand this out for read-only views.
function counter(): ReadonlySignal<number> {
return new NativeSignal(0); // caller can .get() but not .set()
}Computed<T>
A lazily-evaluated, auto-tracked derived value.
const count = new NativeSignal(2);
const doubled = new Computed(() => count.get() * 2);
doubled.get(); // → 4; evaluates fn on first call, then caches
count.set(5);
doubled.get(); // → 10; re-evaluated because a dependency changed- Lazy by default. The function doesn't run until someone calls
get()orsubscribe(). After the first run, dependency changes mark it stale; the nextget()recomputes. - Dependency tracking is automatic and conditional. Only the signals actually read in a given evaluation are tracked — branches that didn't run aren't dependencies.
- It cuts unnecessary work. If an upstream change doesn't actually alter this computed's output, propagation stops here (downstream consumers aren't re-run).
Eager mode = effects
Pass eager = true (the 3rd constructor arg) to run immediately and re-run on every
dependency change, whether or not anyone reads the value. This is how you do side effects:
// runs now, and again every time `count` changes
const logger = new Computed(() => console.log("count is", count.get()), undefined, true);
own(logger, someElement); // hold it — see the GC rule aboveThe 2nd argument is an optional context passed to the function — use it to share one
static function across many computeds instead of allocating a closure each time (a
micro-optimization):
const c = new Computed(self => self.a.get() + self.b.get(), { a, b });Stopping a computed
doubled.subscribe(fn); // receive updates on change (this makes the computed eager)
doubled.destroy(); // stop listening to dependencies immediately, don't wait for GCEffect
A multi-source side-effect sink: run a function whenever any named source changes. Use it when you want "do X when any of these change" and don't need a value back.
import { Effect } from "native-signal/weak";
const fx = new Effect(
{ x: signalA, y: signalB }, // named sources
({ x, y }) => console.log(x, y), // values keyed by the same names
);
fx.destroy(); // unsubscribe from all sources immediatelyfn(values, self)receives the latest value of every source, keyed by your source names.- Multiple synchronous source changes within a tick coalesce into one call.
fx.add_listener(key, source)attaches another source at runtime.fx._source_cacheholds the most-recent value seen from each source.- Lifecycle: an
Effectlives as long as something references it (sources hold it weakly). Keep a reference, or calldestroy()to stop it now.
A
Computed(..., undefined, true)and anEffectoverlap: use aComputedwhen one derivation reads several signals; use anEffectwhen you want explicitly named inputs.
detached
Run a block inside a Computed without subscribing to what it reads. Returns the block's
result directly.
import { detached } from "native-signal/weak";
const total = new Computed(() => {
const live = items.get(); // tracked dependency
const snapshot = detached(() => config.get()); // read once, NOT a dependency
return live.length * snapshot.factor;
});In an eager computed you can also use queueMicrotask(...) to run code outside the tracking
scope.
Wrappers & events: local, interval
import { local, interval } from "native-signal/weak";local(key, signal) — persist a signal to localStorage (JSON). On call it loads an
existing value (if any) into the signal, otherwise seeds storage from the signal, then keeps
them in sync. Returns the same signal.
const theme = local("theme", new NativeSignal("light"));
theme.set("dark"); // written to localStorage["theme"]JSON only; browser only. The subscription lives as long as the signal does — keep a reference.
interval(delta) — a NativeSignal<number> that increments every delta ms. Calling
interval(50) twice returns the same signal (one setInterval per delta). Held weakly:
keep a reference to keep it ticking; it fires on interval boundaries, not immediately.
const tick = interval(1000);
const clock = new Computed(() => new Date(tick.get() && Date.now()).toLocaleTimeString());Flushing changes
Subscribers and Effects will run synchronously right after the current task is done to avoid
multiple setters triggering in quick succession (next microtask). You can force the issue by
calling EventManager.flush(), eg for tests.
Collections (WIP)
native-signal ships reactive collections — SignalSet, SignalMap, SignalHeap, and
Order — plus reducers (reduce, count). They expose two notification channels:
- Whole-collection via
subscribe(...)(coalesced on a microtask). - Per-change named events via
subscribe_event("add" | "delete" | ...)(synchronous).
import { SignalMap, SignalSet } from "native-signal/weak";
const users = new SignalMap<string, User>();
users.set("u1", alice);
users.get(); // Map snapshot (a dependency inside a Computed)
const u = users.ref("u1"); // NativeSignal<User|undefined> for one key
u.set(undefined); // setting a ref to undefined deletes the entrySignalMap.ref(key) is the highlight: a per-key signal a Computed can depend on without
re-running when unrelated keys change.
⚠️ Collections are WIP — slower than the core, subject to change, and with at least one known
clear()quirk onSignalMap. Prefer plain signals
Computeduntil you specifically need collection semantics.
