Manual LiveTable

LiveTable

elements man livetable Read as markdown

LiveTable 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:for places the row.
  • The resetUI callback 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.