Todo App
elements man recipes/todo-app Read as markdownA single-page todo list: add, rename inline, check off, star, delete, and
filter by All / Active / Done. Every change appears in every open tab without
any extra wiring, because one LiveTable backs the page.
This is the recipe to read first. It is small enough to hold in your head and it exercises the pieces most apps need: an optimistic insert, a filtered and sorted list that stays live, a filter that lives in the URL, partial updates, and a delete that animates.
Migration
elements create migration 'add todos' -tables=todos
-- add todos
create or replace function touchUpdatedAt()
returns trigger
language plpgsql
as $$
begin
new.updatedAt = now();
return new;
end;
$$;
create table todos (
id uuid primary key default uuidGenerateV7(),
title text not null,
done boolean not null default false,
starred boolean not null default false,
createdAt timestamptz not null default now(),
updatedAt timestamptz not null default now()
);
create index todosDoneCreatedAt on todos (done, createdAt desc);
create trigger todosTouchUpdatedAt
before update on todos
for each row execute function touchUpdatedAt();
The model
app/pages/home/models.ts:
export interface Todo {
id: string;
title: string;
done: boolean;
starred: boolean;
createdAt: Date;
/** Client-only: the row is playing its leave animation. */
leaving?: boolean;
}
export type Filter = "all" | "active" | "done";
export interface View {
filter: Filter;
}
export interface Draft {
title: string;
}
/**
* The row being renamed, and the text so far. A buffer rather than fields on
* the row, so typing never touches the todo.
*/
export interface Edit {
id: string;
title: string;
}
export function matches(todo: Todo, filter: Filter): boolean {
if (filter === "active") {
return !todo.done;
}
if (filter === "done") {
return todo.done;
}
return true;
}
export function byPriority(a: Todo, b: Todo): number {
if (a.starred !== b.starred) {
return a.starred ? -1 : 1;
}
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
}
export function parseFilter(param: unknown): Filter {
return param === "active" || param === "done" ? param : "all";
}
Two things to copy from this file.
Keep view state off the row where you can. editing and saved fields on
a Todo would mean a half-typed title is a todo: it is what a second tab sees
while you are still typing, and cancelling has to remember what to put back.
An Edit buffer beside the table has neither problem. leaving stays on the
row because it is per-row, several rows can be leaving at once, and it decides
nothing but a CSS class.
createdAt is parsed on both sides of the comparator. It is a Date on a
row you just created and a string on one that arrived over the wire.
The route
app/pages/home/index.ts:
import { Request, Response, LiveTable } from "@elements/app";
import { parseFilter, Todo } from "./models";
import html from "./template";
/* No ordering here. How the list is arranged on screen is a view concern and
lives in the template, in byPriority. */
const todos = new LiveTable<Todo>();
export default function route(req: Request, res: Response) {
return new html({ todos: todos.view(), view: { filter: parseFilter(req.query.filter) } });
}
A LiveTable is always live: an insert here is broadcast to every page watching this table. There is nothing to turn on.
The filter lives in the URL as a query param. GET /?filter=done server-renders
with Done already applied, so a reload or a shared link lands on the same view.
req.query holds the query string; parseFilter falls back to all on
anything unrecognized.
Filtering and sorting
filter() and sort() return plain arrays, and the loop is still live.
Iterating the view inside the e:for expression takes a dependency, so when a
row enters or leaves, the expression re-runs and the loop reconciles the new
array against the screen by id. The rows that did not change keep their DOM and
their state:
<ul class="todo-list" e:if={countOf(todos, view.filter) > 0}>
<li e:for={todo of todos.filter((todo) => matches(todo, view.filter)).sort(byPriority)}
class={["todo-item", todo.done && "is-done", todo.leaving && "is-leaving"]}
onanimationend={() => onLeaveEnd(todos, todo)}>
The loop's expression is reactive: it re-runs when anything it read changes,
and the predicate reads view.filter, so a tab click re-filters the list.
Hoisting the expression into a helper like visible(todos, view.filter)
behaves the same way.
Do not render every row and hide some with display: none either. A hidden row
is still in the DOM, still bound, and still counted by anything that walks the
list.
The tabs are a plain template that writes to view through one handler:
<FilterTab (view: View, filter: Filter, label: string, count: number)>
<button class={["tab", view.filter === filter && "is-active"]}
type="button"
aria-pressed={view.filter === filter}
onclick={() => onFilter(view, filter)}>
{label}
<span class="todo-tab-count">{count}</span>
</button>
</FilterTab>
function onFilter(view: View, filter: Filter) {
view.filter = filter;
history.pushState(null, "", filter === "all" ? "/" : `/?filter=${filter}`);
}
The assignment re-renders the list in place; nothing is refetched and the page
never reloads. history.pushState then writes the new URL into the address bar
and adds a history entry, one per filter change. It rewrites the address bar
and nothing more: actual navigation still goes through redirect(url).
The back button walks those entries, and popstate is how the page hears it.
Wire the listener in the root's oninit (elements man html/events) and set
the filter from the URL, which re-renders exactly as a tab click does:
<html class="todos"
(todos: LiveView<Todo>,
view: View,
private draft: Draft = { title: "" },
private edit: Edit = { id: "", title: "" })
oninit={() => window.addEventListener("popstate", () => onPopState(view))}>
function onPopState(view: View) {
view.filter = parseFilter(new URLSearchParams(window.location.search).get("filter"));
}
view is an object rather than a bare attribute because a handler can mutate
an object's fields and have it be live, while reassigning a plain attribute
inside a handler is not (elements man html/attributes).
Counts come off the same view:
function countOf(todos: LiveView<Todo>, filter: Filter): number {
return todos.filter((todo) => matches(todo, filter)).length;
}
Adding
function onAdd(todos: LiveView<Todo>, draft: Draft) {
let title = draft.title.trim();
if (title === "") {
return;
}
/* createdAt is set here even though the column defaults to now(): the
optimistic row is painted from exactly this object, and the order depends
on it. */
todos.insert(
{ title, done: false, starred: false, createdAt: new Date() },
() => draft.title = "",
);
}
The optimistic row is drawn from the object you pass and nothing else. Any
field the template reads has to be in it, including ones the database would
have filled in. Leaving createdAt out here sorts the new row as though it
had no date until the server answers, and it visibly jumps.
The second argument clears the composer. Use it for UI state only, never to navigate: it runs before the write is sent.
Updating
Spread the row and name what changed:
function onToggle(todos: LiveView<Todo>, todo: Todo, done: boolean) {
todos.update({ ...todo, done });
}
function onStar(todos: LiveView<Todo>, todo: Todo) {
todos.update({ ...todo, starred: !todo.starred });
}
An update patches the row in place, so the row keeps its DOM and only the bindings that read the changed field repaint. Listing every column by hand works too, and says less.
Renaming inline
The editor is one input that swaps in for the label, bound to the buffer:
<input class="todo-text"
e:if={edit.id === todo.id}
value={edit.title}
aria-label="Edit todo"
oninsert={(e) => (e.target as HTMLInputElement).select()}
onblur={() => onCommit(todos, edit, todo)}
onkeydown={(e) => {
if (e.key === "Enter") {
(e.target as HTMLInputElement).blur();
}
if (e.key === "Escape") {
onCancelEdit(edit);
}
}} />
<button class="todo-label"
e:else
type="button"
aria-label={`Edit ${todo.title}`}
onclick={() => onEdit(edit, todo)}>
{todo.title}
</button>
function onEdit(edit: Edit, todo: Todo) {
edit.id = todo.id;
edit.title = todo.title;
flush();
}
function onCommit(todos: LiveView<Todo>, edit: Edit, todo: Todo) {
if (edit.id !== todo.id) {
return;
}
edit.id = "";
let title = edit.title.trim();
if (title === "") {
onRemove(todos, todo);
return;
}
if (title === todo.title) {
return;
}
todos.update({ ...todo, title });
}
function onCancelEdit(edit: Edit) {
edit.id = "";
}
flush() mounts the input synchronously inside the click, which is what lets
iOS Safari open the keyboard for it (elements man html/events). Cancelling is
one assignment because the todo was never touched.
Deleting, with an animation
/* Matches --duration-normal, the css the leave animation runs with. */
const LEAVE_MS = 220;
function onRemove(todos: LiveView<Todo>, todo: Todo) {
todo.leaving = true;
setTimeout(() => todos.delete(todo), LEAVE_MS);
}
function onLeaveEnd(todos: LiveView<Todo>, todo: Todo) {
if (todo.leaving) {
todos.delete(todo);
}
}
Two things finish the delete and either may be first. animationend is the
fast path and the usual one. The timer is the backstop, because a tab that is
not being looked at throttles animations and may never raise the event, and a
delete the user asked for has to land regardless.
Whichever arrives second deletes a row that is already gone, and that is a no-op: the table looks the row up by id and returns when it is not there. So neither path needs a flag tracking the other.
Do not use the timer alone. A busy browser can run a 200ms timer seconds late, and the row sits there faded out until it does.
Clearing every done row is the same call in a loop:
function onClearDone(todos: LiveView<Todo>) {
for (let todo of todos.filter((todo) => todo.done)) {
onRemove(todos, todo);
}
}
Empty states
Three of them, because "no todos at all" and "nothing left to do" are different news:
<div e:else class="todo-empty">
<p class="todo-empty-title" e:if={todos.length === 0}>Nothing on the list</p>
<p class="todo-empty-title" e:elseif={view.filter === "active"}>All caught up</p>
<p class="todo-empty-title" e:else>Nothing finished yet</p>
</div>
Styling it
Change colour first and layout second, and delete the scaffolded markup before
you touch layout. See start/patterns for why: a page that is half old markup
and half new CSS looks broken while you work, and if anyone is watching, that
is what they see.
Checking it
A green build means it compiles, not that it works. Open two tabs on
http://localhost:4000/ and confirm each of these:
- Adding a todo in one tab makes it appear in the other.
- Checking one row does not make the rest of the list flash. If it does, the
loop is depending on something it should not; read
livetable. - Deleting a row in the middle removes that row and no other, and leaves no faded row behind.
- Active hides a row the moment you check it, and Done shows it.
- Reloading
/?filter=doneopens on Done, a tab click rewrites the URL with no reload, and the back button steps through the filters you clicked. - Renaming a row and pressing Escape leaves the original title.