0.1.0-alpha.7

State and lists

The list needs items, and the items will change: you will add some, check some off, and remove some. In zerolith, a value that changes over time lives in a signal.

A signal for the items

Create src/store.ts, a module for the list's state:

// The packing list's state: one signal holding every item.
import { signal } from "zerolith";

export interface Item
{
    id: number;
    name: string;
    packed: boolean;
}

export const items = signal<Item[]>([
    { id: 1, name: "Passport", packed: true },
    { id: 2, name: "Phone charger", packed: false },
    { id: 3, name: "Rain jacket", packed: false }
], { name: "items" });

signal takes the starting value and returns an object with two methods: get() reads the value, and set() replaces it. State that more than one component reads lives in src/store.ts, the place every zerolith project keeps it, so any component can import it and it outlives a page that is closed and opened again.

The second argument names the signal. A named signal keeps its value when you save a file: zerolith dev reloads the page and restores every named signal, so the list you were testing with is still there after an edit.

One row per item

Now render the items. Replace src/components/packing.ts:

// The packing list page: a row for every item, kept in step with the list.
import { type View, computed, each, html } from "zerolith";
import { items } from "../store.ts";

export function Packing(): View
{
    const count = computed(() => items.get().length);
    return html`
        <h1>Packing list</h1>
        <p>${count} items</p>
        <ul>
            ${each(items, (item) => item.id, (item) => html`
                <li>${computed(() => item.get().name)}</li>`)}
        </ul>`;
}

Three things are new here.

computed makes a value that follows other signals. count reads items, so whenever items changes, count recomputes, and only then.

each renders a list. It takes the signal holding the array, a key function that says which item is which, and a function that renders one row. When the array changes, each compares keys: a row whose item is still there keeps its element, a new key gets a new row, and a missing key loses its row. Nothing else is touched, so a focused checkbox or a half-typed field in a row survives the update.

The row function receives the item as a signal, not as a plain object, so that computed(() => item.get().name) can follow the item if its name changes later while the row stays in place.

The one rule

Look at how the template uses count: it puts the signal itself in the slot, ${count}, not ${count.get()}. This is the rule to learn first, and it is the only one:

Both are valid code, so the runtime cannot warn you; zerolith doctor and the Vite plugin report a frozen read like that before the code runs, under the code template_frozen_read.

There is no re-rendering in zerolith. Packing() runs once, when the page opens. After that, a change to items updates exactly the text nodes and rows that depend on it.

What you learned

  • A signal holds a value that changes: get() reads it, set() replaces it.
  • computed derives a value from other signals and follows them.
  • each renders keyed rows and keeps each row's element while its key stays.
  • Bind the signal, not its value, and the page stays live.