0.1.0-alpha.7

Components

The page now does two jobs in one function: it renders rows and it handles a form. As it grows, splitting it keeps each part readable. A component is a function, so splitting is moving code into another function.

First add a remove function next to toggle and add, so a row can delete its item:

// The packing list's state and every way it changes.
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" });

export function toggle(id: number): void
{
    items.set(items.get().map((item) =>
        (item.id === id ? { ...item, packed: !item.packed } : item)));
}

export function add(name: string): void
{
    const id = Math.max(0, ...items.get().map((item) => item.id)) + 1;
    items.set([...items.get(), { id, name, packed: false }]);
}

export function remove(id: number): void
{
    items.set(items.get().filter((item) => item.id !== id));
}

A row

Create src/components/item.ts:

// One row of the list. It runs once per item; its bindings follow the item.
import { type ReadableSignal, type View, computed, html } from "zerolith";
import { type Item, remove, toggle } from "../store.ts";

export function ItemRow({ item }: { item: ReadableSignal<Item> }): View
{
    const name = computed(() => item.get().name);
    const packed = computed(() => item.get().packed);
    return html`
        <li>
            <label>
                <input type="checkbox" .checked=${packed}
                    @change=${() => toggle(item.get().id)}>
                ${name}
            </label>
            <button type="button" aria-label="Remove ${name}"
                @click=${() => remove(item.get().id)}>Remove</button>
        </li>`;
}

Props are an object, and TypeScript checks them like any function's arguments. The row receives item as a signal, the same signal each hands to its row function, so the row's bindings follow the item.

The remove button has visible text and an aria-label that names the item, because "Remove" on its own does not say which item a screen reader user is about to remove. An attribute can mix text and bindings: aria-label="Remove ${name}" stays current if the name changes.

The form

Create src/components/add-item.ts, with the form from the last step:

// The form that adds an item, with the field it clears after each one.
import { type View, html } from "zerolith";
import { add } from "../store.ts";

export function AddItem(): View
{
    const submitted = (event: SubmitEvent): void =>
    {
        event.preventDefault();
        const form = event.currentTarget as HTMLFormElement;
        const name = String(new FormData(form).get("name") ?? "").trim();
        if (name !== "")
        {
            add(name);
            form.reset();
        }
    };
    return html`
        <form @submit=${submitted}>
            <label>New item
                <input name="name" autocomplete="off" required>
            </label>
            <button>Add</button>
        </form>`;
}

The page, from its parts

Now src/components/packing.ts puts the parts together:

// The packing list page, put together from its parts.
import { type View, computed, each, html } from "zerolith";
import { items } from "../store.ts";
import { AddItem } from "./add-item.ts";
import { ItemRow } from "./item.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) => ItemRow({ item }))}</ul>
        ${AddItem()}`;
}

A component call goes straight into a slot: ${AddItem()}. Call the function; passing AddItem without calling it is reported as unrenderable_value, since a function is not something a page can show.

Why components run once

Each of these functions runs one time, when its part of the page is created. ItemRow runs once per item, not once per change: checking an item updates the row's checkbox and nothing else. This is why it is safe to create signals and computed values at the top of a component, and why there are no rules about the order of hooks or dependency arrays to remember: there is nothing to re-run.

What you learned

  • Splitting a component is moving code into another function and calling it.
  • Props are an object; a row gets its item as a signal.
  • Components run once, and their bindings keep them current.