LiveTable
elements man livetable Read as markdownLiveTable gives you automatic select, insert, update, and delete, real-time
publish/subscribe, and integration that runs from the server through reactive
HTML in the browser with optimistic UI updates. In practice: declare a LiveTable
on the server, open a view of it in the route, pass that view to the page as a
template attribute, and iterate it with e:for. The page server-renders with
the rows in the first response. When the browser inserts, updates, or deletes a
row, the UI changes immediately while the server reconciles in the background.
When another browser mutates the same table, the broadcast flows back over the
connection and every watching browser patches the affected row in place.
Despite the name, a LiveTable is not tied to one database table. It is a live
set of rows the browser holds and keeps in sync with the server: the rows
arrive in the first render, then insert / update / delete events stream in and
patch them. With zero config it mirrors a single table. With a custom select
its rows can be a join or a projection.
The declaration and the view
There are two types. Everything else in this topic follows from the split.
A LiveTable is the declaration: the columns, the handlers, the channel. It lives at module scope, there is one of it, and it is server-only, because it configures a database and a channel, neither of which a browser has. You never read rows from it and you never write through it.
A LiveView is one request's rows. table.view() in the route reads them
and opens the subscription that keeps them current. That view is what the
template renders, what crosses the wire, and the only thing there is to mutate.
// services.ts: the declaration, and a write that any caller can reach
export const comments = new LiveTable<Comment>();
export function postComment(view: LiveView<Comment>, text: string) {
view.insert({ text, author: session.getOrThrow("userName") });
}
// route.ts: the view
return new html({ comments: comments.view() });
The view is data
A LiveView is an iterable of rows, and every read on it is the matching Array
method over a copy: filter, sort, map, find, at, slice, length,
and the rest of the non-mutating Array surface, plus get(id). They return
plain arrays and mean exactly what they mean on an array.
Every one of those reads, and iterating the view itself, takes one dependency
that fires when a row enters or leaves. So an e:for over the view is live, an
e:for over comments.sort(byTime) is live, and an e:for over a helper that
builds its own array from the view is live too, for the same reason: the loop
re-evaluates what it was given and reconciles by id. A row's fields are
watched separately, so a change to one field repaints the bindings that read
it and nothing else.
<li e:for={c of comments}>{c.text}</li>
<li e:for={c of comments.sort((a, b) => +a.createdAt - +b.createdAt)}>{c.text}</li>
<li e:for={line of feed(comments)}>{line.text}</li>
<span>{comments.length}</span>
Nothing above is a special case. Shape the rows however the page needs.
At a Glance
// app/shared/services/comments.ts
import { LiveTable } from "@elements/app";
interface Comment {
id: string;
text: string;
author: string;
createdAt: Date;
}
export let comments = new LiveTable<Comment>();
// app/pages/comments/index.ts
import { comments } from "#app/shared/services/comments";
export default function route(req, res) {
return new html({ comments: comments.view() });
}
<html (comments: LiveView<Comment>,
private form: { text: string } = { text: "" })>
<ul>
<li e:for={comment of comments.sort((a, b) => +a.createdAt - +b.createdAt)}>
<strong>{comment.author}</strong>: {comment.text}
</li>
</ul>
<form onsubmit={() => {
comments.insert(
{ text: form.text, author: session.get("userName")!, createdAt: new Date() },
() => form.text = "",
);
}}>
<input value={form.text}>
<button>post</button>
</form>
</html>
What happened:
- The page server-renders with the current comments. First paint has data.
comments.insert(...)adds the row optimistically. The UI shows it at once.- The server persists it and broadcasts to every browser watching the table.
- Every other browser receives the broadcast and its
e:forplaces the row. - The
resetUIcallback fires as soon as the optimistic change lands locally, before the server has confirmed. Use it to clear the state that fed the mutation, so the UI is ready for the next action without the round trip.
A view opened while logged in stops when that session ends, by design. On
session.logout(), expiry, or session.revoke(), the server stops the view's
subscription and the browser drops it, so the rows freeze at their last state.
The route authorized that view for that user, and the check does not re-run
when the user changes.
Views opened by an anonymous request are public and keep running, and the
WebSocket stays open. To get live rows again, open a new view: navigate to the
page again or return one from an @rpc. See elements man session.
The pages in this topic cover the rest: options for the declaration,
partitions for one slice of a table per page, windows for paging a long
table, mutations for the three writes, and handlers for custom SQL and
authorization.