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(orconstfor 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$signalprimitive 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
signalneeds the updater form explicitly — see Signals API). - The second argument is the same options object
signaltakes ($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)wrapsobjitself. - Assignment,
delete, and array methods (push,splice,sort,lengthwrites, …) 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.nametracks 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 throwsTypeErroron 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.
Related pages
- 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.