Shared Lists
elements man recipes/shared-lists Read as markdownA todo list whose id lives in the URL. Two browsers on the same /lists/:id see
each other's edits in real time as items are added, toggled, or removed. The
shared id is the only piece of "access control"; anyone with the URL can read
and edit.
Two LiveTables drive the recipe. lists holds the lists themselves and is used
on the home page. listItems is scoped by listId, so a mutation on one list
only broadcasts to browsers watching that list.
Migration
elements create migration 'add lists' -tables=lists,listItems
app/migrations/<timestamp>-add-lists.migration.sql:
-- add lists
-- Auto-update updatedAt on row changes.
create or replace function touchUpdatedAt()
returns trigger
language plpgsql
as $$
begin
new.updatedAt = now();
return new;
end;
$$;
create table lists (
id uuid primary key default uuidGenerateV7(),
createdAt timestamptz not null default now(),
updatedAt timestamptz not null default now(),
title text not null
);
create trigger listsTouchUpdatedAt
before update on lists
for each row execute function touchUpdatedAt();
create table listItems (
id uuid primary key default uuidGenerateV7(),
createdAt timestamptz not null default now(),
updatedAt timestamptz not null default now(),
listId uuid not null references lists(id) on delete cascade,
text text not null,
done boolean not null default false
);
create trigger listItemsTouchUpdatedAt
before update on listItems
for each row execute function touchUpdatedAt();
listItems.listId is the scope column. The LiveTable's scope: "listId"
partition will key broadcasts off this value. The on delete cascade foreign
key cleans up items when a list is deleted.
LiveTables
The LiveTables live in app/shared/services/lists.ts so both pages can import
them.
app/shared/services/lists.ts:
import { LiveTable } from "@elements/app";
export interface List {
id: string;
createdAt: Date;
title: string;
}
export interface ListItem {
id: string;
createdAt: Date;
listId: string;
text: string;
done: boolean;
}
export let lists = new LiveTable<List>({
sort: "createdAt desc",
});
export let listItems = new LiveTable<ListItem>({
scope: "listId",
sort: "createdAt asc",
});
No insert, update, or delete auth callbacks. The recipe's access model is
"anyone with the URL", so all mutators are open by default. To gate by
ownership, add auth callbacks; see elements man livetable ▸ Auth.
Home page
elements create page home
The home page renders every list with a link to its URL, and a form to create a
new list. Creating a list returns its id; the handler navigates to /lists/:id
so the new list opens immediately.
app/pages/home/services.ts:
import { sql } from "@elements/app";
/** @rpc */
export function createList(title: string): string {
return sql<{ id: string }>(
`insert into lists (title) values (${title}) returning id`,
).firstOrThrow().id;
}
app/pages/home/index.ts:
import home from "./template";
import { lists } from "#app/shared/services/lists";
export default function route(req, res) {
return new home({ lists });
}
app/pages/home/template.html:
import "./style.css";
import { LiveTable, redirect } from "@elements/app";
import { List } from "#app/shared/services/lists";
import { createList } from "./services";
function createAndOpen(title: string) {
let id = createList(title);
redirect(`/lists/${id}`);
}
<html class="home" (lists: LiveTable<List>, private title: string = "")>
<h1>your lists</h1>
<ul class="lists">
<li e:for={l of lists}>
<a href={`/lists/${l.id}`}>{l.title}</a>
</li>
</ul>
<form onsubmit={() => createAndOpen(title)}>
<input type="text" value={title} placeholder="new list title" required>
<button type="submit">create</button>
</form>
</html>
The home page receives the unscoped lists LiveTable from the route. Inserts
from any browser appear in the list immediately. The local title is reactive
state for the input.
List page
elements create page list
The list page loads the list's title from the database (a one-time read, not live) and passes the scoped LiveTable of items to the template.
app/pages/list/index.ts:
import { sql } from "@elements/app";
import list from "./template";
import { List, listItems } from "#app/shared/services/lists";
export default function route(req, res) {
let row = sql<List>(
`select id, createdAt, title from lists where id = ${req.params.id}`,
).firstOrThrow("list not found");
return new list({ list: row, items: listItems.scope(row.id) });
}
firstOrThrow("list not found") throws NotFoundError (a safe 404) with the
given message if no row matches. The message reaches the browser as the body of
the response.
app/pages/list/template.html:
import "./style.css";
import { LiveTable } from "@elements/app";
import { List, ListItem } from "#app/shared/services/lists";
function insert(items: LiveTable<ListItem>, draft: { value: string }) {
items.insert(
{ text: draft.value, done: false },
() => draft.value = "",
);
}
function toggle(items: LiveTable<ListItem>, item: ListItem) {
items.update({ ...item, done: !item.done });
}
function remove(items: LiveTable<ListItem>, item: ListItem) {
items.delete(item);
}
<html class="list"
(list: List,
items: LiveTable<ListItem>,
private draft: { value: string } = { value: "" })>
<h1>{list.title}</h1>
<ul class="items">
<li e:for={item of items} class={item.done && "done"}>
<button class="toggle" onclick={() => toggle(items, item)}>{item.done ? "☑" : "☐"}</button>
<span class="text">{item.text}</span>
<button class="remove" onclick={() => remove(items, item)}>×</button>
</li>
</ul>
<form onsubmit={() => insert(items, draft)}>
<input type="text" value={draft.value} placeholder="add an item" required>
<button type="submit">add</button>
</form>
</html>
items.insert(...) adds the row optimistically; the resetUI callback clears
the input the moment the optimistic row lands.
items.update({ ...item, done: !item.done }) and items.delete(item) go
through the LiveTable's mutators so the change broadcasts. Every browser
watching listItems.scope(<this list's id>) patches the affected row in place.
draft is wrapped in { value: ... } because the handler runs through a
function parameter; assigning draft.value = "" mutates the wrapper object
whose reference the template still holds. A plain string parameter would not
flow the assignment back.
Routes
Register the pages in index.ts:
import home from "#app/pages/home";
import list from "#app/pages/list";
// ...
app.route("/", home);
app.route("/lists/:id", list);
Notes
- Open access by design. With no auth callbacks on the LiveTables and no
guard in either route, the only thing keeping a list private is the secrecy of
its uuidv7 id. uuidv7 has 122 bits of entropy, so the id is effectively
unguessable, but anyone you share the URL with can read and edit. To require
login, wrap each route with
session.isLoggedInOrThrow()and addauthcallbacks to the LiveTables. - Per-list ownership. Add an
ownerIdcolumn tolistsand anauth(item, s) => item.ownerId === s.getOrThrow('userId')callback onlistItems.update/deletefor "anyone can read, only owner can edit". The scope still partitions broadcasts by list, so each user only receives notifications for their own lists. - Deleting a list. The
on delete cascadeforeign key onlistItems.listIdremoves a list's items automatically when the list is deleted. Use the unscopedlistsLiveTable'sdeletemutator from a "delete list" button on the home page. - Cross-tab sync. A user with the same list open in two tabs sees both views update in lockstep. The LiveTable's broadcast covers cross-tab and cross-user identically.
- The
listsLiveTable on/lists/:id. This recipe only passeslistItems.scope(id)to the list page. To also show "your other lists" in a sidebar on the list page, passlistsalong too. Both LiveTables coexist without interference.