3. Render a list
Keep many todos in one store, render them with For and Show, and watch a derived count stay in sync.
Goal: a list of todos with toggle and delete buttons, an empty state, and a "left" counter.
The guides Lists and keys and Control flow cover For, Index, Show and Switch in depth.
Move the state into a hook#
State and the operations on it belong together. A hook in ShadowJS is any function that calls the primitives;
there is nothing to register. useTodos owns one deep store and returns the store plus two operations.
export interface Todo {
id: number;
title: string;
done: boolean;
}
import { useStore } from "@shadow-js/core";
import type { Todo } from "./types";
export function useTodos(initial: readonly Todo[] = []) {
const [state, setState] = useStore({ todos: [...initial] });
return {
state,
toggle(id: number) {
setState("todos", (todo) => todo.id === id, "done", (done) => !done);
},
remove(id: number) {
setState((draft) => {
const index = draft.todos.findIndex((todo) => todo.id === id);
if (index !== -1) draft.todos.splice(index, 1);
});
},
};
}
state is a read-only view; the only way to change it is setState, which has three forms used here and later:
| Form | Example | Use it for |
|---|---|---|
| Path | setState("todos", (todo) => todo.id === id, "done", true) |
Changing one field of one or more items. A step is a key, an index, a filter function or a range. |
| Draft function | setState((draft) => { draft.todos.push(todo) }) |
Several changes at once, push, splice, anything you would write on a plain object. |
| Merge or replace | setState({ filter: "done" }), setState(reconcile(next)) |
Assign keys; or diff new data into the store. |
Every call is one batch: readers of what changed are notified once. See SetStore.
The item component#
TodoItem now receives the whole todo. Because a store item is a reactive view, props.todo.done and
props.todo.title update on their own; the component does not need accessors.
import type { Todo } from "./types";
export interface TodoItemProps {
todo: Todo;
onToggle: () => void;
onRemove: () => void;
}
export function TodoItem(props: TodoItemProps) {
return (
<li classList={{ done: props.todo.done }}>
<label>
<input type="checkbox" checked={props.todo.done} onChange={() => props.onToggle()} />
{props.todo.title}
</label>
<button
type="button"
aria-label={`Delete ${props.todo.title}`}
onClick={() => props.onRemove()}
>
Delete
</button>
</li>
);
}
The list#
<For> calls its child function once for every item and keeps the resulting rows. When
the list changes it adds, removes and moves rows; it does not rebuild the rows that stay.
<Show> renders its children while when is truthy and fallback otherwise.
import { For, Show } from "@shadow-js/web";
import { TodoItem } from "./TodoItem";
import { useTodos } from "./todos";
export function App() {
const todos = useTodos([
{ id: 1, title: "Learn ShadowJS", done: false },
{ id: 2, title: "Write a todo app", done: true },
]);
const remaining = todos.state.todos.filter((todo) => !todo.done).length;
return (
<main>
<h1>Todos</h1>
<Show when={todos.state.todos.length > 0} fallback={<p>Nothing to do.</p>}>
<ul>
<For each={todos.state.todos}>
{(todo) => (
<TodoItem
todo={todo}
onToggle={() => todos.toggle(todo.id)}
onRemove={() => todos.remove(todo.id)}
/>
)}
</For>
</ul>
</Show>
<p>{remaining} left</p>
</main>
);
}
Points to take from this file:
<For each={todos.state.todos}>withoutkeymatches rows by item identity. Store items keep their identity for as long as they are in the store, so a row survives a toggle and a removal elsewhere in the list. The child receives the item itself (todo) and, as a second argument, an index accessor. For plain data that is replaced wholesale, passkey={(item) => item.id}; the child then receives an accessor (todo().title).const remaining = todos.state.todos.filter(...).lengthis an ordinary constant, and it still updates. Inside a component the compiler turns aconstthat reads reactive data into a cached derived value, so{remaining}is live. Everything else about this rule, including what it does not cover, is on Forgiving reactivity. In step 5 you write the same thing withuseMemo.<Show>and<For>insert their rows between the surrounding elements without any comment nodes or wrapper elements.
What you should see#
Two items appear, the second one checked, with “1 left” below. Toggle an item to update its class and the counter. Remove an item to remove its row; the remaining rows keep their DOM nodes.
Next: 4. Add todos with a form.