0.1.0-alpha.7

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 equal

set 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());               // 30

Dependencies 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", once

Ownership

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 gone

Async 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