Manual HTML RPC and LiveTable in Templates

RPC and LiveTable in Templates

elements man html/integration Read as markdown

Calling an @rpc from a template and rendering a LiveTable, the two places html meets the rest of the framework.

RPC + HTML

Mark a function @rpc to call it from the browser. The compiler wires the network call.

/** @rpc */
function saveUser(form: Form): User {
  return sql<User>(`insert into users (name, age) values (${form.name}, ${form.age}) returning *`).firstOrThrow("insert returned no row");
}

Call it like a normal function from a handler.

<form onsubmit={() => saveUser(form)}>
  <input value={form.name}>
  <input type="number" value={form.age}>
</form>

Elements primitives (sql, @rpc, tx, session, Channel, LiveTable) are sync-style: you write them without async/await. The compiler converts them to async at build time and propagates the conversion up the call stack. If you want the async version directly, import the xAsync variant (sqlAsync, txAsync, insertAsync).

let user = saveUser(form);
let users = sql<User>(`select * from users`);

When you genuinely need explicit async (third-party libraries like the Stripe SDK, fetch, navigator.clipboard), declare the function async and await those specific calls. The build leaves manual async/await alone.

LiveTable + HTML

LiveTable provides crud and realtime for any reactive data source. The source can be a database table, a view, or anything else you can read and write. By default a LiveTable binds to a database table, but you can override the crud handlers to back it with whatever you want.

let comments = new LiveTable<Comment>();

This binds to the comments db table (variable name → table name, camelCase to snake_case). Realtime is on by default, so every page that uses comments gets cross-client updates. Pass realtime: false for a snapshot-only read.

In the template, iterate with e:for:

<ul>
  <li e:for={comment of comments}>{comment.text}</li>
</ul>

Mutate from event handlers. insert, update, and delete take an optional resetUI callback whose only job is to clear local UI state (form fields, drafts, edit toggles) after the optimistic write. Use it to reset UI, nothing else. Put navigation like redirect() in the handler flow, not in resetUI:

comments.insert({ text: form.text }, () => form.text = "");
comments.update({ ...comment, text: editText }, () => editing = false);
comments.delete(comment);

All three are sync-style with optimistic UI. The browser updates immediately. The server reconciles afterward. On failure, the optimistic change rolls back.

e:for over a LiveTable does O(1) updates per notify message. No re-render of the full list.

Optimistic inserts must include every field the template displays. The optimistic row holds only the object you pass. If the template shows comment.author, insert({ text }) will render undefined for author until the server reconciles. Include displayed fields at insert time even if the server canonicalizes them. Server-generated columns the template reads count too: a displayed createdAt (default now()) is undefined on the optimistic row, so new Date(undefined) renders Invalid Date until the broadcast lands. Pass a client-side placeholder like createdAt: new Date().

Full LiveTable details (scopes, custom select, custom handlers, realtime modes): livetable.