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 opened per list with view({ 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 partition column: listItems.view({ listId }) keys
the channel 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>();
export let listItems = new LiveTable<ListItem>();
No insert, update, or delete handlers. The recipe's access model is
"anyone with the URL", so all mutators are the open auto-SQL. To gate by
ownership, add handlers; see elements man livetable.
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: lists.view() });
}
app/pages/home/template.html:
import "./style.css";
import { LiveView, 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: LiveView<List>, private title: string = "")>
<main>
<h1>your lists</h1>
<ul class="lists">
<li e:for={l of lists.sort((a, b) => +b.createdAt - +a.createdAt)}>
<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>
</main>
</html>
The home page receives a view of the whole lists table 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 list's partition 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.view({ listId: 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 { LiveView } from "@elements/app";
import { List, ListItem } from "#app/shared/services/lists";
function insert(items: LiveView<ListItem>, draft: { value: string }) {
items.insert(
{ text: draft.value, done: false },
() => draft.value = "",
);
}
function toggle(items: LiveView<ListItem>, item: ListItem) {
items.update({ ...item, done: !item.done });
}
function remove(items: LiveView<ListItem>, item: ListItem) {
items.delete(item);
}
<html class="list"
(list: List,
items: LiveView<ListItem>,
private draft: { value: string } = { value: "" })>
<main>
<h1>{list.title}</h1>
<ul class="items">
<li e:for={item of items.sort((a, b) => +a.createdAt - +b.createdAt)}
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>
</main>
</html>
items.insert(...) adds the row optimistically; the partition fills listId,
so the payload carries only text and done, and 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 view's mutators so the change broadcasts. Every browser watching
listItems.view({ listId }) for this list 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 handlers 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 add handlers to the LiveTables that call it too. - Per-list ownership. Add an
ownerIdcolumn tolists, then inupdateanddeletehandlers onlistItemscallsession.isLoggedInOrThrow(), look the owner up, and throwForbiddenErrorwhen it is notsession.getOrThrow('userId'), for "anyone can read, only the owner can edit". The partition still splits broadcasts by list, so each browser only receives notifications for the list it has open. - Deleting a list. The
on delete cascadeforeign key onlistItems.listIdremoves a list's items automatically when the list is deleted. Use thelistsview'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.view({ listId })to the list page. To also show "your other lists" in a sidebar on the list page, passlists.view()along too. Both views coexist without interference.