5. Filters and derived state
Show all, active or done todos with a memo, derive counts from the store and clear completed items.
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#
export type Filter = "all" | "active" | "done";
export interface Todo {
id: number;
title: string;
done: boolean;
}
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.
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#
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>
);
}
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:
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.