TodoMVC
The classic TodoMVC, implemented in kerf. ~150 lines. Persists across reloads via localStorage. Editing a todo by double-click; Esc cancels, Enter or blur commits. The new-todo input keeps focus and cursor position even when the list reorders around it.
What to look at:
- One store holds items, filter, and the in-edit id. All state changes go through named actions.
attr()action map —ACTIONS.toggleetc. is defined once; JSX spreads{...ACTIONS.toggle.attrs}(rename-safe, no hardcoded attribute name) and the delegate calls pass the pre-escapedACTIONS.toggle.selector. Renaming the action in one place updates JSX + delegate together.effect()round-trips tolocalStorage— read on startup, write on every state change. No middleware, no plugin.- Memo key includes the edit state —
\${todo.id}-${editingId === todo.id ? ‘edit’ : ‘view’}`` — so the row’s HTML is recomputed when it flips between view and edit modes, but cached otherwise. delegateCapture('blur', ...)for the edit input commits on blur.blurdoesn’t bubble, so capture-phase is required.- Focus survival is automatic — typing into the new-todo while items are added/toggled never disturbs the cursor.
import { defineStore, mount, each, delegate, delegateCapture, effect, attr, type AttrSpec,} from "kerfjs";
const ACTIONS = { toggle: attr("data-action", "toggle"), remove: attr("data-action", "remove"), edit: attr("data-action", "edit"), filter: attr("data-action", "filter"), clearDone: attr("data-action", "clear-done"),} as const satisfies Record<string, AttrSpec<"data-action">>;
const ITEM = { id: attr("data-id") } as const;
interface Todo { id: string; text: string; done: boolean;}type Filter = "all" | "active" | "done";
const STORAGE_KEY = "kerf-todomvc";
function load(): Todo[] { try { const raw = localStorage.getItem(STORAGE_KEY); return raw ? (JSON.parse(raw) as Todo[]) : []; } catch { return []; }}
const todos = defineStore({ initial: () => ({ items: load(), filter: "all" as Filter, editingId: null as string | null, }), actions: (set, get) => ({ add: (text: string) => { const t = text.trim(); if (!t) return; set({ ...get(), items: [ ...get().items, { id: crypto.randomUUID(), text: t, done: false }, ], }); }, toggle: (id: string) => set({ ...get(), items: get().items.map((it) => it.id === id ? { ...it, done: !it.done } : it, ), }), remove: (id: string) => set({ ...get(), items: get().items.filter((it) => it.id !== id) }), clearDone: () => set({ ...get(), items: get().items.filter((it) => !it.done) }), setFilter: (filter: Filter) => set({ ...get(), filter }), startEdit: (id: string) => set({ ...get(), editingId: id }), commitEdit: (id: string, text: string) => { const t = text.trim(); if (!t) { set({ ...get(), items: get().items.filter((it) => it.id !== id), editingId: null, }); return; } set({ ...get(), items: get().items.map((it) => it.id === id ? { ...it, text: t } : it, ), editingId: null, }); }, cancelEdit: () => set({ ...get(), editingId: null }), }),});
effect(() => { localStorage.setItem(STORAGE_KEY, JSON.stringify(todos.state.value.items));});
const root = document.getElementById("app")!;
mount(root, () => { const { items, filter, editingId } = todos.state.value; const visible = items.filter((it) => filter === "active" ? !it.done : filter === "done" ? it.done : true, ); const remaining = items.filter((it) => !it.done).length;
return ( <div class="todoapp"> <ul class="todo-list"> {each( visible, (todo) => ( <li data-key={todo.id} class={editingId === todo.id ? "editing" : ""} > <input type="checkbox" {...ACTIONS.toggle.attrs} {...ITEM.id(todo.id)} checked={todo.done} /> <label {...ACTIONS.edit.attrs} {...ITEM.id(todo.id)}> {todo.text} </label> <button {...ACTIONS.remove.attrs} {...ITEM.id(todo.id)}> × </button> </li> ), (todo) => `${todo.id}-${editingId === todo.id ? "edit" : "view"}`, )} </ul> <span>{remaining} left</span> <button {...ACTIONS.clearDone.attrs}>Clear completed</button> </div> );});
// Tier 1 click delegations — `.selector` is pre-computed and CSS-escaped:delegate(root, "click", ACTIONS.toggle.selector, (_e, el) => todos.actions.toggle((el as HTMLElement).dataset.id!),);delegate(root, "click", ACTIONS.remove.selector, (_e, el) => todos.actions.remove((el as HTMLElement).dataset.id!),);delegate(root, "click", ACTIONS.edit.selector, (_e, el) => todos.actions.startEdit((el as HTMLElement).dataset.id!),);delegate(root, "click", ACTIONS.filter.selector, (_e, el) => todos.actions.setFilter((el as HTMLElement).dataset.value as Filter),);delegate(root, "click", ACTIONS.clearDone.selector, () => todos.actions.clearDone(),);
// Enter on the new-todo input.delegate(root, "keydown", "[data-new]", (e, el) => { if ((e as KeyboardEvent).key !== "Enter") return; const input = el as HTMLInputElement; todos.actions.add(input.value); input.value = "";});
// Enter / Esc on the edit input.delegate(root, "keydown", "[data-edit]", (e, el) => { const ev = e as KeyboardEvent; const input = el as HTMLInputElement; if (ev.key === "Enter") todos.actions.commitEdit(input.dataset.id!, input.value); else if (ev.key === "Escape") todos.actions.cancelEdit();});
// Tier 2 (capture): blur doesn't bubble.delegateCapture(root, "blur", "[data-edit]", (_e, el) => { const input = el as HTMLInputElement; if (todos.state.value.editingId === input.dataset.id) { todos.actions.commitEdit(input.dataset.id!, input.value); }});