Derived state and conditions
Two questions come up while packing: how much is left, and what exactly is left. Both answers are derived from the items, so they are computed values rather than more state to keep in sync.
Derived values
Update src/store.ts:
// The packing list's state, what follows from it, and every way it changes.
import { computed, signal } from "zerolith";
export interface Item
{
id: number;
name: string;
packed: boolean;
}
export type Filter = "all" | "left" | "packed";
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 const filter = signal<Filter>("all", { name: "filter" });
export const packedCount = computed(() =>
items.get().filter((item) => item.packed).length);
export const shown = computed(() =>
{
const which = filter.get();
return which === "all"
? items.get()
: items.get().filter((item) => item.packed === (which === "packed"));
});
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));
}filter is new state: which items to show. packedCount and shown are derived: they read items and filter and recompute when either changes. Nothing ever sets them directly, so they can never disagree with the items.
A computed value is lazy and cached. It recomputes only when something it read has changed and something reads it again, so shown costs nothing while no page shows it.
The filter buttons
Create src/components/filters.ts:
// The buttons that choose which items the list shows.
import { type View, computed, html } from "zerolith";
import { type Filter, filter } from "../store.ts";
const choices: Array<{ value: Filter; label: string }> = [
{ value: "all", label: "All" },
{ value: "left", label: "Left to pack" },
{ value: "packed", label: "Packed" }
];
export function Filters(): View
{
return html`
<div role="group" aria-label="Show">
${choices.map(({ value, label }) => html`
<button type="button"
aria-pressed=${computed(() => filter.get() === value)}
@click=${() => filter.set(value)}>${label}</button>`)}
</div>`;
}The three buttons come from an array with map, since the choices are fixed; each is for lists that change. aria-pressed tells assistive technology which button is on, and the computed value keeps it current: true and false become the attribute's text.
Conditions
Update src/components/packing.ts:
// The packing list page: progress, the filters, the rows, and a note when
// the filter leaves nothing to show.
import { type View, computed, each, html, when } from "zerolith";
import { items, packedCount, shown } from "../store.ts";
import { AddItem } from "./add-item.ts";
import { Filters } from "./filters.ts";
import { ItemRow } from "./item.ts";
export function Packing(): View
{
const total = computed(() => items.get().length);
const empty = computed(() => shown.get().length === 0);
return html`
<h1>Packing list</h1>
<p>${packedCount} of ${total} packed</p>
${Filters()}
<ul>${each(shown, (item) => item.id, (item) => ItemRow({ item }))}</ul>
${when(empty, () => html`<p>Nothing to show here.</p>`)}
${AddItem()}`;
}each now renders shown instead of items, so the rows follow the filter. when renders its view while the condition is true and removes it when the condition turns false; an optional third argument renders while it is false. Here it shows a note when the filter leaves no rows, such as choosing Left to pack once everything is packed.
What you learned
- Derived values are computed, never set, so they cannot drift from their sources.
- Computed values are lazy and cached.
whenshows a view while a condition holds;eachis for lists that change,mapfor fixed ones.