Tutorial: Todo App
You'll learn: each lists, add/toggle/delete, derived counts, the immutable-update pattern. Time: 10 minutes.
import { html, mount, each, createState } from "../src/index.js";
function Todo() {
const todos = createState([]); // [{ id, text, done }]
const draft = createState("");
const add = () => {
const text = draft.get().trim();
if (!text) return;
todos.set([...todos.get(), { id: Date.now(), text, done: false }]);
draft.set("");
};
const toggle = (id) =>
todos.set(todos.get().map((t) => (t.id === id ? { ...t, done: !t.done } : t)));
const remove = (id) =>
todos.set(todos.get().filter((t) => t.id !== id));
return html`
<div>
<input
value=${() => draft.get()}
oninput=${(e) => draft.set(e.target.value)}
onkeyup=${(e) => { if (e.key === "Enter") add(); }} />
<button onclick=${add}>Add</button>
<ul>${each(
() => todos.get(),
(t) => t.id,
(todo) => html`
<li class=${() => (todo.done ? "done" : "")}>
<input type="checkbox" checked=${() => todo.done} onchange=${() => toggle(todo.id)} />
<span>${() => todo.text}</span>
<button onclick=${() => remove(todo.id)}>✕</button>
</li>`
)}</ul>
<p>${() => todos.get().filter((t) => !t.done).length} remaining</p>
</div>
`;
}
mount(Todo, document.querySelector("#app"));Key ideas#
- The list uses
each(() => todos.get(), t => t.id, …). The key ist.id(stable) — never the index. - Immutable updates.
add,toggle, andremoveeachseta new array.togglekeeps unchanged items' object references (tis returned as-is), so only the toggled<li>re-renders — the rest of the list isn't touched. - Derived count.
${() => todos.get().filter(...).length} remainingupdates whenever the list changes. (Pull it into acomputedif you use it in several places.) - Input clears after adding because
value=${() => draft.get()}is bound to the property andaddsetsdraftto"".
Why keys matter here#
Toggle a todo and notice the other rows' DOM nodes are reused — Zoijs matched them by key and only updated the one that changed. If you keyed by index instead, reordering or deleting would shuffle state between rows.
Try it yourself#
- Add a "Clear completed" button:
todos.set(todos.get().filter(t => !t.done)). - Add filter tabs (All / Active / Done) using a
computedview oftodos. - Persist to
localStorage(set it inside your handlers; read it for the initial state).
Next: Reorderable list »