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 live filtered and sorted view, 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();
}
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 { Todo } from "./models";
import html from "./template";
/* Two orderings, on purpose. `sort` is the ORDER BY the query runs with, so a
page arrives already in a sensible order. How the list is arranged on screen
is a view concern and lives in the template, in byPriority. */
const todos = new LiveTable<Todo>({ sort: "createdAt desc" });
export default function route(req: Request, res: Response) {
return new html({ todos });
}
Realtime is the default, so an insert here is broadcast to every page watching this table. There is nothing to turn on.
Filtering and sorting
filter() and sort() return a live view of the table, not a copy. An
e:for over one is fed a single insert, delete or move when something changes,
so 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)}>
Write the filter(...) in the loop's own expression, not behind a helper.
The loop re-runs when something it read changes, and an inline predicate reads
view.filter each time the rows are walked. Hoisting it into
visible(todos, view.filter) reads view.filter once, while the argument is
being evaluated, and the loop never learns that the tabs can change it: the
list renders once and then ignores every tab click, while the tab's own
highlight updates and makes it look like the filter ran.
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:
<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={() => view.filter = filter}>
{label}
<span class="todo-tab-count">{count}</span>
</button>
</FilterTab>
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 live view:
function countOf(todos: LiveTable<Todo>, filter: Filter): number {
return todos.filter((todo) => matches(todo, filter)).length;
}
Adding
function onAdd(todos: LiveTable<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: LiveTable<Todo>, todo: Todo, done: boolean) {
todos.update({ ...todo, done });
}
function onStar(todos: LiveTable<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: LiveTable<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: LiveTable<Todo>, todo: Todo) {
todo.leaving = true;
setTimeout(() => todos.delete(todo), LEAVE_MS);
}
function onLeaveEnd(todos: LiveTable<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: LiveTable<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.
- Renaming a row and pressing Escape leaves the original title.