[]previous and next page

5. Filters and derived state

Show all, active or done todos with a memo, derive counts from the store and clear completed items.

For agentsUpdated

Goal: three filter buttons, a list that shows only the matching todos, a "Clear completed" button and an empty message that depends on the filter. The guide State with useStore lists every way to derive values.

Three ways to derive a value#

The visible list and the counters are all computed from state.todos and state.filter. ShadowJS gives you three ways to write that, and this step uses all three on purpose.

Way Written as Recomputes Use it for
Memo const visible = useMemo(() => ...) then visible() Only when a dependency changed; the result is cached. Anywhere: hooks, modules, components. Expensive or shared values.
Derived constant const remaining = state.todos.filter(...).length Same as a memo; the compiler creates it. Inside components only. The shortest form.
Function const remaining = () => ... then remaining() On every call. Cheap values read in one place.

A memo is the primitive; the derived constant is the compiler writing a memo for you. Outside a component (in useTodos, a module, a test) the compiler does not rewrite anything, so write useMemo there.

The filter in the hook#

src/types.tsts
export type Filter = "all" | "active" | "done";

export interface Todo {
  id: number;
  title: string;
  done: boolean;
}
src/todos.tsts
import { useMemo, useStore } from "@shadow-js/core";
import type { Filter, Todo } from "./types";

function matches(filter: Filter, todo: Todo): boolean {
  if (filter === "active") return !todo.done;
  if (filter === "done") return todo.done;
  return true;
}

export function useTodos(initial: readonly Todo[] = []) {
  const [state, setState] = useStore({ todos: [...initial], filter: "all" as Filter });
  const visible = useMemo(() => state.todos.filter((todo) => matches(state.filter, todo)));

  return {
    state,
    visible,
    add(title: string): boolean {
      const text = title.trim();
      if (text === "") return false;
      setState((draft) => {
        const id = draft.todos.reduce((max, todo) => Math.max(max, todo.id), 0) + 1;
        draft.todos.push({ id, title: text, done: false });
      });
      return true;
    },
    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);
      });
    },
    setFilter(filter: Filter) {
      setState("filter", filter);
    },
    clearCompleted() {
      setState((draft) => {
        for (let index = draft.todos.length - 1; index >= 0; index -= 1) {
          if (draft.todos[index]?.done === true) draft.todos.splice(index, 1);
        }
      });
    },
  };
}

visible reads state.filter and every todo.done, so it re-runs when the filter changes, when a todo is added or removed, or when any todo is toggled. It returns the same store items, which is what lets <For> keep the rows that stay visible. A row that leaves the view is disposed; if it comes back it is created again.

clearCompleted removes items from the array with splice inside the draft, which keeps the other items (and their rows) in place.

The filter buttons#

A small presentational component: the current value comes in as a prop, a change goes out as a callback.

src/Filters.tsxtsx
import { For } from "@shadow-js/web";
import type { Filter } from "./types";

const OPTIONS: readonly { value: Filter; label: string }[] = [
  { value: "all", label: "All" },
  { value: "active", label: "Active" },
  { value: "done", label: "Done" },
];

export interface FiltersProps {
  value: Filter;
  onChange: (filter: Filter) => void;
}

export function Filters(props: FiltersProps) {
  return (
    <nav aria-label="Filter">
      <For each={OPTIONS}>
        {(option) => (
          <button
            type="button"
            aria-pressed={props.value === option.value}
            onClick={() => props.onChange(option.value)}
          >
            {option.label}
          </button>
        )}
      </For>
    </nav>
  );
}

OPTIONS never changes, so a plain constant array is enough. aria-pressed={props.value === option.value} is a binding: when the filter changes, two buttons get a new attribute value and nothing else happens.

The page#

src/AddTodo.tsxtsx
import { onMount, useStore } from "@shadow-js/core";

export interface AddTodoProps {
  onAdd: (title: string) => void;
}

export function AddTodo(props: AddTodoProps) {
  const [title, setTitle] = useStore("");
  let input: HTMLInputElement | undefined;

  onMount(() => {
    input?.focus();
  });

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault();
        props.onAdd(title());
        setTitle("");
      }}
    >
      <input
        ref={(element) => {
          input = element;
        }}
        type="text"
        aria-label="New todo"
        placeholder="What needs to be done?"
        value={title()}
        onInput={(event) => setTitle(event.currentTarget.value)}
      />
      <button type="submit" disabled={title().trim() === ""}>
        Add
      </button>
    </form>
  );
}
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>
  );
}

In App, the counters are derived constants, the second one built on the first (completed reads remaining), and the empty message is a derived conditional:

src/App.tsxtsx
import { For, Show } from "@shadow-js/web";
import { AddTodo } from "./AddTodo";
import { Filters } from "./Filters";
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;
  const completed = todos.state.todos.length - remaining;
  const empty =
    todos.state.filter === "all" ? "Nothing to do." : `No ${todos.state.filter} todos.`;

  return (
    <main>
      <h1>Todos</h1>
      <AddTodo onAdd={todos.add} />
      <Filters value={todos.state.filter} onChange={todos.setFilter} />
      <Show when={todos.visible().length > 0} fallback={<p>{empty}</p>}>
        <ul>
          <For each={todos.visible()}>
            {(todo) => (
              <TodoItem
                todo={todo}
                onToggle={() => todos.toggle(todo.id)}
                onRemove={() => todos.remove(todo.id)}
              />
            )}
          </For>
        </ul>
      </Show>
      <p>{remaining} left</p>
      <Show when={completed > 0}>
        <button type="button" onClick={() => todos.clearCompleted()}>
          Clear completed ({completed})
        </button>
      </Show>
    </main>
  );
}

What you should see#

Three buttons under the form, the list narrowed by the selected one, "1 left" and a Clear completed (1) button that disappears when nothing is completed. Switch between All, Active and Completed to narrow the list. A filter with no matching items shows its empty message; rows that remain visible keep their DOM nodes.

Next: 6. Persistence and effects.