Signals
Everything that changes in a zerolith application is a signal, and everything that follows a change reads one. The graph has three kinds of node, and the rest of the library is built from them:
| Node | Holds | Runs |
|---|---|---|
signal(value) |
State: a value you set | Never; it notifies what read it |
computed(fn) |
A value derived from other nodes | When read after one of its sources changed |
effect(fn) |
Nothing; it acts on the outside world | At once, then again after each change to what it read |
A template binding is an effect too: ${count} in a template is an effect that writes one text node. That is why a page stays current without its components running again.
Reading and writing
get() reads a signal and set() writes it. A read inside a computed value or an effect subscribes it; a read anywhere else is a plain read.
import { signal } from "zerolith";
const count = signal(0);
count.set(count.get() + 1);
console.log(count.get()); // 1
const point = signal({ x: 1 }, { equals: (a, b) => a.x === b.x });
point.set({ x: 1 }); // ignored: `equals` says it is equalset ignores a value equal to the current one, by Object.is unless the signal was given its own equals. An ignored write notifies nothing, so a write that changes nothing costs nothing downstream.
signal and computed take the same options:
| Option | Default | What it does |
|---|---|---|
name |
None | Labels the signal in the inspector; zerolith dev and the Vite plugin keep a named signal's value across an edit |
equals |
Object.is |
Decides whether a new value counts as a change; a value it calls equal notifies nothing |
Derived values
A computed value runs its function the first time it is read, remembers the result, and runs again only when a signal it read has changed and something reads it again:
import { computed, signal } from "zerolith";
const price = signal(10);
const quantity = signal(2);
const total = computed(() => price.get() * quantity.get());
console.log(total.get()); // 20
quantity.set(3);
console.log(total.get()); // 30Dependencies come from what the function actually read on its last run, so a branch it did not take subscribes to nothing, and a computed value nobody reads costs nothing at all.
Effects
An effect runs its function at once, then again after any change to a signal it read. It can return a cleanup function, which runs before its next run and when it is disposed:
import { effect, signal } from "zerolith";
const name = signal("Ada");
const dispose = effect(() =>
{
const current = name.get();
console.log(`hello ${current}`);
return () => console.log(`goodbye ${current}`);
}); // prints "hello Ada" right away
name.set("Grace"); // prints "goodbye Ada", "hello Grace"
dispose(); // prints "goodbye Grace"Effects are for the world outside the graph: the document title, storage, a timer, a socket, a third-party widget. An effect created inside another effect belongs to it and is disposed when the outer effect runs again.
An effect that keeps changing what it reads would run forever; after 100 rounds it fails with effect_loop.
Reading without subscribing
untracked reads signals without making the running effect or computed value depend on them, for a value that should be current when the effect runs but should not make it run:
import { effect, signal, untracked } from "zerolith";
const query = signal("zerolith");
const page = signal(1);
effect(() =>
{
const from = untracked(() => page.get());
console.log(`search ${query.get()} from page ${from}`);
}); // prints "search zerolith from page 1"
page.set(2); // prints nothing: page is untracked
query.set("signals"); // prints "search signals from page 2"Batching
Writes inside batch are applied together, and the effects they affect run once, after the function returns. Writes inside an effect are batched the same way without asking.
import { batch, effect, signal } from "zerolith";
const first = signal("Ada");
const last = signal("Lovelace");
// Prints "Ada Lovelace" now, and again after each change:
effect(() => console.log(`${first.get()} ${last.get()}`));
batch(() =>
{
first.set("Grace");
last.set("Hopper");
}); // prints "Grace Hopper", onceOwnership
Every effect belongs to an owner, and disposing the owner stops all of its effects at once. render owns what a view creates, each row of an each and each branch of a when owns what it creates, and each page of the router owns its own. scope makes an owner by hand, and onDispose adds cleanup to the owner that is running:
import { effect, onDispose, scope, signal } from "zerolith";
const tick = signal(0);
const handle = scope(() =>
{
effect(() => console.log(`tick ${tick.get()}`));
onDispose(() => console.log("disposed"));
}); // prints "tick 0"
tick.set(1); // prints "tick 1"
handle.dispose(); // prints "disposed"
tick.set(2); // prints nothing: the effect is goneAsync values
resource loads data from signals. Its params function reads the signals the request depends on, and its loader receives those params and an abort signal. Whenever the params change, the previous request is aborted and the loader runs again; when params returns undefined, the resource waits, idle.
import { resource, signal } from "zerolith";
const userId = signal<number | undefined>(undefined);
const user = resource({
params: () => userId.get(),
loader: async ({ params, abort }) =>
{
const response = await fetch(`/api/users/${params}`, { signal: abort });
return (await response.json()) as { id: number; name: string };
}
});
console.log(user.status.get()); // "idle": params returned undefined
userId.set(7);
console.log(user.status.get()); // "pending"
// Once the loader settles, the status is "resolved" and user.get() is the user.A resource is itself a signal of its value, with its state beside it:
| Member | Holds |
|---|---|
get() |
The value the last successful load returned, or undefined before one has. It stays while the next load runs and after a load fails. |
status |
"idle", "pending", "resolved", or "error" |
pending |
true while a load runs |
error |
What the last failed load threw, until the next load starts |
reload() |
Runs the loader again with the current params |
A result that arrives after newer params have started a load is dropped, so a slow first request never overwrites a fast second one. The abort signal fires when the params change, on reload(), and when the resource's owner is disposed; passing it to fetch, as above, cancels the request itself.
The functions at a glance
| Function | Takes | Returns |
|---|---|---|
signal(value, options) |
The starting value, and name and equals |
A signal, with get() and set(value) |
computed(fn, options) |
A function that reads signals, and name and equals |
A read-only signal of what fn returns |
effect(fn) |
A function that may return a cleanup | A function that disposes the effect |
untracked(fn) |
A function whose reads subscribe nothing | What fn returns |
batch(fn) |
A function whose writes apply together | What fn returns |
scope(fn) |
A function whose effects belong to a new owner | The owner, with value, what fn returned, and dispose() |
onDispose(fn) |
A cleanup | Nothing; the running owner calls fn when it is disposed |
resource({ params, loader }) |
A tracked params function, and a loader that receives { params, abort } |
A resource, as the table above describes |