Signals API
Reference for the reactive primitives, stores, ownership, and render bindings.
The low-level signal, computed, and action functions live in
@rezejs/signals; applications normally use the $ compiler syntax instead
(see Reactivity). Import from "reze-js" unless noted. For
concepts and worked examples, see Reactivity and
Lifecycle; this page states exact signatures and semantics
without repeating those guides.
Compiler-only entries ($signal, $computed, $action) throw requires the reze compiler when they run uncompiled — they are syntax, not runtime
functions.
Signals
import { signal, type Equals, type Getter, type Setter } from "@rezejs/signals";
const [count, setCount] = signal(0);
const [name, setName] = signal("a", { equals: false });
signal<T>(initialValue: T, options?: SignalOptions<T>): [Getter<T>, Setter<T>]signal<T>(): [Getter<T | undefined>, Setter<T | undefined>]type Getter<T> = () => Ttype Setter<T> = (next: T | ((prev: T) => T)) => T— writesnext, or the result of calling it with the latest written value, and returns it. To store a function, pass an updater returning it:setHandler(() => () => name()).type Equals<T> = false | ((prev: T, next: T) => boolean);options.equalsdefaults toObject.is.falsenotifies on every write.options.nameis the name devtools show; ignored in production builds.
Reads track the calling computation and return the current value. Writes store
synchronously and notify subscribers unless equals reports the value equal
to the latest write; subscriber re-runs are queued for the microtask flush
(see flush). Separate getter/setter values let the compiler prove a $signal
constant by tracking the setter alone.
import { $signal } from "reze-js";
let count = $signal(0);
$signal<T>(initialValue: T, options?: SignalOptions<T>): T$signal<T>(): T | undefined- Declare with
let(written) orconst(read-only) in the file that uses it; the declaration cannot be exported or destructured. Reads compile to getter calls, assignments and compound operators to setter calls. Reading the variable — including passing its value to a call — simply tracks; using the$signalprimitive itself as a value instead of calling it is refused.
Computed
import { computed, signal } from "@rezejs/signals";
const [count] = signal(2);
const doubled = computed(() => count() * 2);
const total = computed((prev = 0) => prev + count());
computed<T>(getter: (previousValue?: T) => T, options?: ComputedOptions): () => T- Lazily evaluated and cached: recomputes on read after a dependency changed,
notifies only when the result differs by
Object.is. The getter receives the previous value. Nodes created in the getter are owned by the computation and disposed before each recomputation. Graphs must be acyclic — in development, reading a computed while it evaluates warns and returns the stale value. - A throw in the getter propagates to the reader; under
catchErrorit goes to the handler and the computed keeps its previous value.
import { $computed, $signal } from "reze-js";
let count = $signal(2);
const doubled = $computed(count * 2);
$computed<T>(value: T, options?: ComputedOptions): T— compiles tocomputed(() => count() * 2).- Pass the expression, not a function; read-only; never written, exported, or
destructured. Reading the variable simply tracks; using the
$computedprimitive itself as a value instead of calling it is refused. Noawaitin the expression (awaitinside a nested function is allowed). A simple arrow wrapper around the expression is refused with a fix that unwraps it; other function forms are refused without one.
Effects
import { signal } from "@rezejs/signals";
import { effect, flush } from "reze-js";
const [count, setCount] = signal(0);
const stop = effect(() => {
console.log(count());
return () => console.log("cleanup");
});
setCount(1);
flush();
stop();
effect(fn: () => (() => void) | void): () => void— runsfnnow and again, on the next flush, whenever what it read changes. The returned function runs before the next run and on disposal. Nested nodes dispose first, newest first. Returns the disposer.flush(): void— runs every queued subscriber now, including those queued while it runs. Writes in one task coalesce into one run per subscriber; values themselves update synchronously on write.trigger(fn: () => void): void— runsfn, then notifies the subscribers of every dependency it read and flushes synchronously.selector<K>(source: Getter<K>, equals?: (key: K, value: K) => boolean): (key: K) => boolean— subscribes the caller to one key's match result only, so selecting one row out of n costs O(1) instead of O(n). Defaults toObject.is; a customequalsre-checks every live key on each change. Lives as long as the current owner. Outside a computation it just compares.
import { signal } from "@rezejs/signals";
import { effect, selector } from "reze-js";
const [id] = signal(1);
const isSelected = selector(id);
effect(() => console.log("row 2:", isSelected(2)));
The effect re-runs only when row 2 flips between selected and unselected.
Ownership
import { signal } from "@rezejs/signals";
import { effect, effectScope, getOwner, onCleanup, root, runWithOwner, untrack } from "reze-js";
const [count] = signal(0);
const disposeRoot = root((dispose) => {
onCleanup(() => console.log("teardown"));
return dispose;
});
const stop = effectScope(() => {
effect(() => console.log("scoped"));
});
const value = untrack(() => count());
runWithOwner(getOwner(), () => onCleanup(() => console.log("late")));
type Owner— opaque handle to a node that owns computations (root,effect,computed, render bindings).root<T>(fn: (dispose: () => void) => T): T— runsfnuntracked in a new owner detached from the current one. Everything inside lives untildisposeis called; it still sees the current owner's context.onCleanup(fn: () => void): void— runsfn, untracked, when the current owner re-runs or is disposed; cleanups and owned nodes release newest first. No-op without an owner.untrack<T>(fn: () => T): T/untrack<T, A>(fn: (arg: A) => T, arg: A): T— runsfnwithout tracking reads; computations created inside stay owned by the current owner.effectScope(fn: () => void): () => void— runsfnin a new owner that lives as long as the current one; reads infnnever re-run it. Returns the disposer.getOwner(): Owner | undefined— the node owning computations created right now, if any.runWithOwner<T>(owner: Owner | undefined, fn: () => T): T— runsfnuntracked underowner, so nodes created inside are owned by it; for work resumed after anawait.
Stores
import { readonly, store } from "reze-js";
const state = store({ user: { name: "a" }, list: ["c"] });
const view = readonly(state);
store<T extends object>(init: T): T— a deep reactive object.initis adopted, not copied. Reads track every own data property of every plain object and array in the tree as if it were asignal(plus one signal per key set); writes — assignment,delete, array methods — change the object in place and notify immediately. Accessor properties and frozen data values read through untracked. In development, writing while a computed or render binding runs throws.readonly<T extends object>(state: T): T— a view with the same tracked reads that throwsTypeErroron every write.
Actions
See Reactivity for complete $action and action examples.
$action<Args extends unknown[], R>(fn: (...args: Args) => R | PromiseLike<R>, options?: ActionOptions): Action<Args, R>— compiler syntax; store writes in the body, also after eachawait, are speculative until the call settles. Writes in nested functions run outside the action. Compiles toaction, threading the run through everyawait. Using the$actionprimitive itself as a value instead of calling it is refused; the declared action is an ordinary value.action<Args extends unknown[], R>(fn: (run: Run, ...args: Args) => R | PromiseLike<R>, options?: ActionOptions): Action<Args, R>— each call runsfn(run, ...args)withruncurrent, so store writes it makes are kept on success and undone on failure. Only store writes are journaled; signal writes stay immediate. In development, calling an action while a computed or render binding runs throws.interface Action<Args, R> { (...args: Args): Promise<R>; readonly pending: number; readonly error: unknown }— the promise rejects with what the run threw after its writes are undone;pendingcounts runs in flight (tracked);errorholds the latest failure, cleared when the next run starts (tracked).interface Run { resume<T>(value?: T): T; suspend<T>(value: T): T; end(): void }— makes the run current acrossawaits; hand-written actions callresumeafter everyawaitandendat the end of the body.
Async computations and boundaries
See Lifecycle for a complete asyncComputed and boundary example.
asyncComputed<T>(fn: (c: AsyncContext) => PromiseLike<T> | T): AsyncComputed<T>— runsfnnow, and again on the next flush whenever a source it read synchronously or throughc.getchanges. Settlements of superseded runs, or after disposal, are dropped. Rejections and synchronous throws land inerror(), never rethrown.interface AsyncContext { get<T>(source: () => T): T }— readssourceas a dependency of this run, also after anawait; untracked once a newer run started or the computation was disposed.interface AsyncComputed<T> { value(): T | undefined; isPending(): boolean; error(): unknown }—value()is the latest resolved value (undefineduntil the first run resolves, kept while a re-run is pending); a tracked pre-settlement read marks the nearest enclosingboundarypending.isPending()reports the latest run;error()its rejection, cleared when a later run resolves. All three are tracked.boundary<T>(fn: () => T): [T, Boundary]— runsfnuntracked under a new owner, living as long as the current one, that counts pending first loads read inside it; the nearest boundary counts each read.interface Boundary { isPending(): boolean; dispose(): void }—isPendingis tracked;disposetears down everything created inside.isInBoundary(): boolean— whether the current owner sits inside aboundary.
Context and errors
import { catchError, createContext, provideContext, useContext } from "reze-js";
const Theme = createContext("light");
const bare = createContext<string>();
provideContext(Theme, "dark", () => {
console.log(useContext(Theme));
});
catchError(
() => {
throw new Error("boom");
},
(error) => console.error(error),
);
createContext<T>(): Context<T>/createContext<T>(defaultValue: T): Context<T>— a context that is also its own provider component:<Theme value="dark">children</Theme>. Without a default, reading outside a provider throwsContextNotFoundError.provideContext<T, R>(context: ContextKey<T>, value: T, fn: () => R): R— runsfnuntracked under a new owner providingvalue; visible to anything created inside, now or later. Lives as long as the current owner.useContext<T>(context: ContextKey<T>): T— nearest enclosing provider's value, else the default; default-less without a provider throwsContextNotFoundError.catchError<T>(fn: () => T, handler: (error: unknown) => void): T | undefined— a synchronous throw fromfncallshandlerand returnsundefined; a throw from any effect, binding, or computed created inside also goes tohandler(a computed then keeps its previous value). NestedcatchErrors catch first; handler throws propagate outward. Lives as long as the current owner.
Misc
createUniqueId(): string— an id that is a valid CSS identifier and unique on the page ("r0","r1", …).renderEffect<T>(fn: (prev: T) => T, init?: T): void(from@rezejs/signals/render, re-exported byreze-js) — the binding primitive the framework builds templates on: runsfn(init)now and again whenever what it read changes, passing the previous result. A binding that read nothing reactive and created nothing is dropped immediately. Marked pure in development: writing stores or calling actions inside throws.
Related pages
- Reactivity — signals, derivations, stores, and actions in practice.
- Lifecycle — scheduling, ownership, context, and boundaries in practice.
- Components — template bindings and component owners.
- Building interfaces — full UI flows.