A LiveTable is a set of database rows that stays in sync in every browser watching it. You declare it on the server, open a view of it in the route and loop over the view in the template. When anyone adds, changes or deletes a row, their own page updates at once and every other open page follows, with no polling, no WebSocket code, no client cache and no API to write. Chat, live dashboards, shared boards and multiplayer games are built on it.
1. Declare the LiveTable
app/shared/services/comments.tsimport { LiveTable } from "@elements/app"; export interface Comment { id: string; text: string; author: string; createdAt: Date; } export let comments = new LiveTable<Comment>();
That one line gives you reads, inserts, updates and deletes against the
comments table, and the live channel that carries every change. The table
name comes from the variable name, so comments reads and writes comments.
The declaration is server-only, like sql().
2. Open a view in the route
app/pages/comments/index.tsimport { Request, Response } from "@elements/app"; import { comments } from "#app/shared/services/comments"; export default function route(req: Request, res: Response) { return new html({ comments: comments.view() }); }
comments.view() reads the rows and opens the subscription that keeps them
current. The view is what the page receives.
3. Loop over the view in the template
app/pages/comments/template.ehtmlimport { session } from "@elements/app"; import type { LiveView } from "@elements/app"; import type { Comment } from "#app/shared/services/comments"; interface CommentForm { text: string; } function oldestFirst(a: Comment, b: Comment) { return a.createdAt.getTime() - b.createdAt.getTime(); } function onSubmit(comments: LiveView<Comment>, form: CommentForm) { comments.insert({ text: form.text, author: session.get("userName")!, createdAt: new Date(), }, () => form.text = ""); } <html ( comments: LiveView<Comment>, private form: CommentForm = { text: "" }, )> <ul> <li e:for={comment of comments.sort(oldestFirst)}> <strong>{comment.author}</strong>: {comment.text} </li> </ul> <form onsubmit={() => onSubmit(comments, form)}> <input value={form.text} /> <button>Post</button> </form> </html>
That is a live comment feed. Open it in two browser windows side by side and post in one:
- The page arrives with the comments already in it, rendered on the server.
comments.insert()shows the new comment in that window at once, while the server saves it.- The server broadcasts the saved row, and every other window watching the table places it in the list.
- The second argument runs as soon as the comment appears, so the input is clear and ready for the next one.
A view reads like an array: sort, filter, map, find and length all
work, and every loop over them stays live. A change to one field repaints only
what reads that field.
Optimistic writes
An optimistic write updates the page the moment the user acts, without waiting
for the server. The browser applies the change to its own copy of the rows
first, and the server saves it in the background. When the saved row comes
back, it is merged into the row already on the page, so values only the
database knows, like the real createdAt, replace the placeholders. If the
server refuses the write, the change is rolled back and the error reaches the
page.
Declaring a LiveTable creates its write functions for you: insert, update
and delete, server functions the browser calls through the view, the way it
calls an RPC function. You never write them or wire them up, and each one is
optimistic. Each takes an optional second argument,
a callback that runs as soon as the change appears on the page, before the
server has answered. Use it to reset the UI that drove the write: clear the
input, close the editor.
Step 3 already used one: the insert clears the input in its callback. An edit and a delete work the same way:
app/pages/comments/template.ehtmlinterface Edit { text: string; open: boolean; } function onSave(comments: LiveView<Comment>, comment: Comment, edit: Edit) { comments.update({ ...comment, text: edit.text, }, () => edit.open = false); } function onDelete(comments: LiveView<Comment>, comment: Comment) { comments.delete(comment); }
Call onSave from a Save button, and the edited text is on the page and the
editor has closed before the request has left the browser. Every other window
shows the edit a moment later.
- Pass every field the template shows. The optimistic row holds only what
you pass, so the insert in step 3 includes
authorand acreatedAtplaceholder. Leave one out and the page shows it empty until the server's row arrives. - Navigate after the write, not in the callback. The callback runs before
the write reaches the server, so leaving the page there would cancel it. Put
redirect()on the next line instead: the build awaits the write first.
One slice of a table per page
Most pages want part of a table: the comments on one post, the messages in one
room. Pass the columns to view():
app/pages/post/index.tsimport { Request, Response } from "@elements/app"; import { comments } from "#app/shared/services/comments"; export default function route(req: Request, res: Response) { return new html({ comments: comments.view({ postId: req.params.id }) }); }
That object names the slice. Each slice gets its own channel, so a comment on
one post only reaches browsers watching that post. An insert through the view
fills in postId for you, and a write that names a different post is refused.
Decide who can write
With no options, a LiveTable's inserts, updates and deletes are open: any page
holding a view can write through it. Add a handler to decide who may. session
works inside it, the same as in an RPC function:
app/shared/services/comments.tsimport { LiveTable, ForbiddenError, session } from "@elements/app"; export let comments: LiveTable<Comment> = new LiveTable<Comment>({ insert: (item) => { session.isLoggedInOrThrow(); return comments.insert(item); }, update: (item) => { if (item.author !== session.get("userName")) { throw new ForbiddenError(); } return comments.update(item); }, delete: () => { throw new ForbiddenError(); }, });
Handlers run on the server. The page shows the change at once, and if the handler refuses it, the change is rolled back and the error reaches the page.
Reading is decided where the view is opened. view() only runs on the server,
so the route or RPC function that calls it checks who is asking, and the
browser can never widen the view it was given. When the visitor signs out, the
views opened for them stop.
Writes from anywhere else
Only writes made through a view reach open pages. A write through a view
broadcasts wherever it runs, in the browser or in a route, an RPC function, a
job or a test. A row written with a plain sql() call or in elements db
lands in the table without telling anyone. To make every write live, whatever its source, add a Postgres
trigger that notifies the table's channel. The
live from SQL recipe (opens in a new tab) shows how.
Channels, for events that are not rows
A channel sends a message from the server to every browser listening on it. Use one for events that are not rows in a table, such as who is typing or an export that finished:
app/shared/services/alerts.tsimport { Channel } from "@elements/app"; export interface Alert { userId: string; text: string; } export const alerts = new Channel<Alert>("alerts");
The route gives the page a listener, filtered to the signed-in user:
app/pages/home/index.tsimport { Request, Response, session } from "@elements/app"; import { alerts } from "#app/shared/services/alerts"; export default function route(req: Request, res: Response) { return new html({ alerts: alerts.filter((a) => a.userId === session.getOrThrow("userId")), }); }
The filter runs on the server, so a browser never receives another user's
alerts. Send one from any server code with alerts.notify({ userId, text }).
Built on the Postgres you already have
LiveTables and channels run on Postgres LISTEN and NOTIFY, and every app
server listens. A change made through one server reaches browsers connected to
any of them, with no message broker or extra service to run.
Try a LiveTable
Run the todo recipe (opens in a new tab), or ask your agent for a live list in your app. Open it in two browser windows side by side, add an item in one, and watch it appear in the other.
See live data in a demo
- Sketchmoor (opens in a new tab), a shared whiteboard with live cursors.
- Micpass (opens in a new tab), live Q&A, polls and word clouds for a room full of phones.
- Glowhollow (opens in a new tab), community chat with presence, reactions and unread counts.
The manual: livetable (opens in a new tab), channel (opens in a new tab), session (opens in a new tab), rpc (opens in a new tab), recipes (opens in a new tab).