--- title: "Live Data" author: "@chris" author_url: https://elements.dev/u/chris published: 2026-10-02T15:11:56.316Z url: https://elements.dev/feed/01a0fd2c-01ab-7747-8fca-d9c94c8a9102 kind: lesson format: article --- # Live Data by [@chris](https://elements.dev/u/chris) ยท 2026-10-02 ## Description Chat, dashboards, multiplayer games. Every open browser sees each change as it happens. 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 ```typescript 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(); ``` 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 ```typescript 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 ```ehtml 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, form: CommentForm) { comments.insert({ text: form.text, author: session.get("userName")!, createdAt: new Date(), }, () => form.text = ""); } , private form: CommentForm = { text: "" }, )>
onSubmit(comments, form)}>
``` 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: ```typescript app/pages/comments/template.ehtml interface Edit { text: string; open: boolean; } function onSave(comments: LiveView, comment: Comment, edit: Edit) { comments.update({ ...comment, text: edit.text, }, () => edit.open = false); } function onDelete(comments: LiveView, 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()`: ```typescript 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: ```typescript app/shared/services/comments.ts import { LiveTable, ForbiddenError, session } from "@elements/app"; export let comments: LiveTable = new LiveTable({ 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](/learn/man/recipes/live-from-sql) 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: ```typescript app/shared/services/alerts.ts import { Channel } from "@elements/app"; export interface Alert { userId: string; text: string; } export const alerts = new Channel("alerts"); ``` The route gives the page a listener, filtered to the signed-in user: ```typescript 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](/learn/man/recipes/todo-app), 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](https://elements.dev/demos/01a0f3cc-bac6-7c63-807c-cad09fc8582f), a shared whiteboard with live cursors. - [Micpass](https://elements.dev/demos/01a0f3c8-b03e-7fa7-84c4-5f186a0fe28a), live Q&A, polls and word clouds for a room full of phones. - [Glowhollow](https://elements.dev/demos/01a0db1e-6bb5-7fe0-b637-9a1d9abcc6a6), community chat with presence, reactions and unread counts. The manual: [livetable](/learn/man/livetable), [channel](/learn/man/channel), [session](/learn/man/session), [rpc](/learn/man/rpc), [recipes](/learn/man/recipes).