Live Data

00 Markdown

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.ts
import { 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.ts
import { 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.ehtml
import { 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.ehtml
interface 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 author and a createdAt placeholder. 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.ts
import { 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.ts
import { 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.ts
import { 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.ts
import { 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

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).

Get a digest to your inbox once per week.

Comments · 0