[]previous and next page

3. Render a list

Keep many todos in one store, render them with For and Show, and watch a derived count stay in sync.

For agentsUpdated

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.

src/types.tsts
export interface Todo {
  id: number;
  title: string;
  done: boolean;
}
src/todos.tsts
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.

src/TodoItem.tsxtsx
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.

src/App.tsxtsx
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}> without key matches 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, pass key={(item) => item.id}; the child then receives an accessor (todo().title).
  • const remaining = todos.state.todos.filter(...).length is an ordinary constant, and it still updates. Inside a component the compiler turns a const that 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 with useMemo.
  • <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.