Skip to content

Reactivity

Reze tracks reads and writes at the binding level. A signal holds one value; a computed derives from other values; a store holds a mutable object tree. Reads inside a computation subscribe it, and writes re-run exactly the subscribers that read the changed value. Nothing re-renders.

The two ways to spell signals are interchangeable. $signal, $computed, and $action are compiler sugar and the default in applications: they read and write like plain variables, and the compiler rewrites them into the runtime calls below. The runtime calls — signal, computed, action — live in @rezejs/signals and work without the compiler for hand-written code. The sugar throws requires the reze compiler if it ever runs uncompiled.

Signals

Declare a reactive variable with $signal. Reads look like variable reads; writes look like assignments:

import { $signal } from "reze-js";

export function Counter() {
  let count = $signal(0);
  return (
    <section>
      <output>{count}</output>
      <button onClick={() => (count += 1)}>+1</button>
      <button onClick={() => (count = 0)} disabled={count === 0}>
        reset
      </button>
    </section>
  );
}

The compiler turns each read into a getter call and each write into a setter call, so the code above runs as if it used the signal runtime directly:

import { signal } from "@rezejs/signals";

export function Counter() {
  const [count, setCount] = signal(0);
  return (
    <section>
      <output>{count()}</output>
      <button onClick={() => setCount(count() + 1)}>+1</button>
      <button onClick={() => setCount(0)} disabled={count() === 0}>
        reset
      </button>
    </section>
  );
}

Compound assignments (+=, ++, ||=, …) become read-then-write setter calls. An assigned expression that reads other signals is wrapped in an updater closure, so it reads fresh values when the write lands:

import { $signal } from "reze-js";

let base = $signal(2);
let scaled = $signal(0);

export function rescale(factor: number) {
  scaled = base * factor;
}

Sugar rules, enforced by the compiler:

  • Declare with let (or const for a value you never write) in the file that uses the variable. The declaration cannot be exported or destructured.
  • Reading the variable — including passing its value to a call such as pick(list) — simply tracks. What is refused is using the $signal primitive itself as a value instead of calling it.
  • To store a function in a signal, assign the function itself; the compiler wraps right-hand sides that need it so the value is not mistaken for an updater (hand-written signal needs the updater form explicitly — see Signals API).
  • The second argument is the same options object signal takes ($signal("a", { equals: false })).

A write updates the value synchronously and schedules subscribers on a microtask automatically — handwritten effects and framework bindings alike. flush() only forces queued subscribers to run synchronously, which is handy in tests (see Lifecycle). Writes compare with Object.is by default and skip notification when the value is equal; pass { equals: false } to notify on every write, or a custom equals(prev, next) predicate.

Computed values

Derive with $computed. Pass the expression itself, not a function — the compiler wraps it:

import { $computed, $signal } from "reze-js";

export function Cart() {
  let price = $signal(6);
  let qty = $signal(2);
  const total = $computed(price * qty);
  return (
    <p>
      <button onClick={() => (qty += 1)}>Qty: {qty}</button>
      Total: {total}
    </p>
  );
}

This runs as computed(() => price() * qty()). A computed is lazy and cached: it recomputes on read after a dependency changed, and notifies its own readers only when the result differs by Object.is. The runtime getter also receives the previous value — computed((prev = 0) => ...) — for folding derivations.

Limits, enforced by the compiler:

  • A computed is read-only. Every write form (=, +=, ++, destructuring, for...of) is refused.
  • The argument must be an expression, not a function literal. A simple arrow wrapper around the expression is refused with a fix that unwraps it; other function forms are refused without one.

Computed graphs must be acyclic: in development, reading a computed while it is being evaluated warns and returns the stale value. Computations created inside a computed getter are owned by it and disposed before each recomputation, so a re-derivation never leaks its inner effects.

For async derivations, use asyncComputed from the runtime (there is no $ form). It runs now and re-runs on the next flush when a source read synchronously — or through c.get after an await — changes:

import { signal } from "@rezejs/signals";
import { asyncComputed } from "reze-js";

const [userId, setUserId] = signal(1);

const user = asyncComputed(async (c) => {
  const id = c.get(userId);
  const res = await fetch(`/users/${id}`);
  return res.json();
});

export function Name() {
  return <p>{user.isPending() ? "Loading…" : (user.value()?.name ?? "?")}</p>;
}

value() holds the latest resolved value (undefined until the first run resolves, kept while a re-run is pending). isPending() and error() report state; a rejection or synchronous throw lands in error() and never rethrows. A settlement from a superseded run — or from after the owner was disposed — is dropped. Reads of c.get after the run was superseded or disposed are untracked. A tracked read of value() before the first run settles marks the nearest enclosing boundary pending.

Stores

store makes a deep reactive object. Reads track per key, writes land in place and notify immediately:

import { For, store } from "reze-js";

const todos = store([{ id: 1, done: false }]);

export function List() {
  return (
    <ul>
      <For each={todos}>
        {(todo) => (
          <li>
            <input type="checkbox" checked={todo.done} onChange={() => (todo.done = !todo.done)} />
          </li>
        )}
      </For>
      <button onClick={() => todos.push({ id: todos.length + 1, done: false })}>add</button>
    </ul>
  );
}

Semantics:

  • The initializer is adopted, not copied: store(obj) wraps obj itself.
  • Assignment, delete, and array methods (push, splice, sort, length writes, …) change the object in place and notify readers of the affected keys, plus readers of the key set when keys are added or removed.
  • Nested plain objects and arrays are wrapped on read, so state.user.name tracks exactly that path.
  • Accessor properties and frozen data values are read through without tracking.
  • readonly(state) returns a view with the same tracked reads that throws TypeError on every write.

In development, writing a store while a computed or render binding runs throws: derive the value instead, or write from an event handler or an effect.

Actions

An action wraps an async (or sync) function whose store writes are speculative: visible at once, kept when the call succeeds, undone when it fails. $action compiles to the action runtime and threads the run context through every await:

import { $action, For, Show, store } from "reze-js";

interface Todo {
  id: number;
  done: boolean;
  updatedAt?: number;
}

async function saveTodo(todo: Todo): Promise<Todo> {
  const res = await fetch(`/todos/${todo.id}`, {
    method: "PATCH",
    body: JSON.stringify({ done: todo.done }),
  });
  if (!res.ok) throw new Error(`save failed: ${res.status}`);
  return res.json() as Promise<Todo>;
}

const todos = store<Todo[]>([{ id: 1, done: false }]);

const toggle = $action(async (todo: Todo) => {
  todo.done = !todo.done;
  const saved = await saveTodo(todo);
  todo.updatedAt = saved.updatedAt;
});

export function List() {
  return (
    <ul>
      <For each={todos}>
        {(todo) => (
          <li>
            <input type="checkbox" checked={todo.done} onChange={() => toggle(todo).catch(() => {})} />
          </li>
        )}
      </For>
      <Show when={toggle.pending > 0}>
        <p>Saving…</p>
      </Show>
      <Show when={toggle.error !== undefined}>
        <p>Saving failed; the list shows the pre-save state.</p>
      </Show>
    </ul>
  );
}

Only store writes are speculative. Signal writes inside an action are immediate and permanent, and writes inside nested functions or deferred callbacks (setTimeout, .then) run outside the action — the compiler warns on member writes in functions that run later. Each call returns a promise that rejects with what the run threw after its writes were undone, so the event handler above catches the rejection while toggle.error drives the failure message. pending counts runs in flight and error holds the latest failure until the next call; both are tracked, so the Show branches above update for free. Overlapping runs on one key stay isolated per run: a failure undoes only that run's layers. A synchronous action keeps its writes on return and undoes them on a throw. Using the $action primitive itself as a value instead of calling it is refused; the declared action is an ordinary value — export it and call it from event handlers or effects.

Hand-written action takes the run explicitly and must resume after every await — which is exactly what $action generates:

import { action, type Run } from "@rezejs/signals";

interface Todo {
  id: number;
  done: boolean;
  updatedAt?: number;
}

async function saveTodo(todo: Todo): Promise<Todo> {
  const res = await fetch(`/todos/${todo.id}`, {
    method: "PATCH",
    body: JSON.stringify({ done: todo.done }),
  });
  if (!res.ok) throw new Error(`save failed: ${res.status}`);
  return res.json() as Promise<Todo>;
}

const toggle = action(async (run: Run, todo: Todo) => {
  try {
    todo.done = !todo.done;
    const saved = run.resume(await run.suspend(saveTodo(todo)));
    todo.updatedAt = saved.updatedAt;
  } finally {
    run.end();
  }
});

In development, calling an action while a computed or render binding runs throws; call it from an event handler or an effect.

  • Signals API — exact signatures and semantics for every primitive, including the runtime forms of the sugar here.
  • Lifecycle — effect scheduling, ownership and cleanup, context, and async boundaries.
  • Components — how bindings subscribe in templates.
  • Building interfaces — putting signals, stores, and actions together in UI flows.