Skip to content

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> = () => T
  • type Setter<T> = (next: T | ((prev: T) => T)) => T — writes next, 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.equals defaults to Object.is. false notifies on every write.
  • options.name is 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) or const (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 $signal primitive 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 catchError it 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 to computed(() => count() * 2).
  • Pass the expression, not a function; read-only; never written, exported, or destructured. Reading the variable simply tracks; using the $computed primitive itself as a value instead of calling it is refused. No await in the expression (await inside 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 — runs fn now 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 — runs fn, 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 to Object.is; a custom equals re-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 — runs fn untracked in a new owner detached from the current one. Everything inside lives until dispose is called; it still sees the current owner's context.
  • onCleanup(fn: () => void): void — runs fn, 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 — runs fn without tracking reads; computations created inside stay owned by the current owner.
  • effectScope(fn: () => void): () => void — runs fn in a new owner that lives as long as the current one; reads in fn never 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 — runs fn untracked under owner, so nodes created inside are owned by it; for work resumed after an await.

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. init is adopted, not copied. Reads track every own data property of every plain object and array in the tree as if it were a signal (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 throws TypeError on 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 each await, are speculative until the call settles. Writes in nested functions run outside the action. Compiles to action, threading the run through every await. Using the $action primitive 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 runs fn(run, ...args) with run current, 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; pending counts runs in flight (tracked); error holds 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 across awaits; hand-written actions call resume after every await and end at 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> — runs fn now, and again on the next flush whenever a source it read synchronously or through c.get changes. Settlements of superseded runs, or after disposal, are dropped. Rejections and synchronous throws land in error(), never rethrown.
  • interface AsyncContext { get<T>(source: () => T): T } — reads source as a dependency of this run, also after an await; 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 (undefined until the first run resolves, kept while a re-run is pending); a tracked pre-settlement read marks the nearest enclosing boundary pending. isPending() reports the latest run; error() its rejection, cleared when a later run resolves. All three are tracked.
  • boundary<T>(fn: () => T): [T, Boundary] — runs fn untracked 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 } — isPending is tracked; dispose tears down everything created inside.
  • isInBoundary(): boolean — whether the current owner sits inside a boundary.

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 throws ContextNotFoundError.
  • provideContext<T, R>(context: ContextKey<T>, value: T, fn: () => R): R — runs fn untracked under a new owner providing value; 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 throws ContextNotFoundError.
  • catchError<T>(fn: () => T, handler: (error: unknown) => void): T | undefined — a synchronous throw from fn calls handler and returns undefined; a throw from any effect, binding, or computed created inside also goes to handler (a computed then keeps its previous value). Nested catchErrors 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 by reze-js) — the binding primitive the framework builds templates on: runs fn(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.