Manual LiveTable Mutations

Mutations

elements man livetable/mutations Read as markdown

Inserting, updating, and deleting through a LiveTable, including optimistic updates.

Mutators are sync-style and optimistic. The browser updates immediately, the server reconciles asynchronously, and on failure the optimistic UI update is reverted.

comments.insert({ text: "hi", author: "alice" }, () => form.text = "");
comments.update({ ...comment, text: "edited" }, () => setEditing(false));
comments.delete(comment);

insert, update, and delete accept a resetUI callback as a second argument. It fires as soon as the optimistic change is applied locally, before the server has acknowledged. Use it to clear the state that drove the mutation: form inputs, the row that was being edited, any draft data tied to that action. It is for resetting UI state only, not for navigation or other side effects.

Do not navigate or redirect from resetUI. The callback runs before the write reaches the server. Navigating there tears the page down and aborts the write, so the row never reaches the database. Put navigation on the line after the mutation instead. The mutators are sync-style, so the compiler awaits the write before the next line runs:

posts.insert({ title, url });
redirect("/");

For a create-then-navigate flow with no on-screen list to update, use a plain @rpc that returns the new row rather than LiveTable.insert. The optimistic insert is built for adding a row to a list the user is currently looking at; a submit page that creates one record and leaves has no such list, and the @rpc avoids the optimistic machinery entirely.

That flow can redirect from inside the @rpc itself, which removes the timing question altogether. redirect() works on the server, and from an @rpc the browser navigates only once the call settles, so the write is committed before the page is torn down:

/** @rpc */
export function createPost(title: string, url: string) {
  let id = sql(`insert into posts (title, url) values (${title}, ${url}) returning id`)
    .first()!.id;

  redirect(`/posts/${id}`);
}

insertAsync, updateAsync, and deleteAsync are explicit-promise variants for genuine promise control (for example, Promise.all over several writes). They are not a fix for a mutation that seems not to persist. That is almost always navigation firing too early, addressed by the pattern above. Do not reach for async/await here: the sync-style mutators already await the write for you.

Including Every Displayed Field

The optimistic row renders before the server reconciles. It holds only the object you pass to insert(...), nothing else. If the template displays comment.author, an optimistic insert({ text }) renders undefined for author until the server's reply arrives. Pass every field the template reads at insert time, even if the server canonicalizes them.

// browser shows undefined author until reconcile
comments.insert({ text: form.text });

// browser shows the author immediately
comments.insert({ text: form.text, author: session.get("userName")! });

This includes server-generated columns the template reads: timestamps like createdAt (default now()), sequence numbers, and anything a database trigger fills in. Those values exist only after the row round-trips, so the optimistic row has them as undefined until reconcile. A displayed createdAt is the common trap: new Date(undefined) renders Invalid Date for the first frames, then corrects itself once the broadcast lands, which reads as a flicker with no obvious cause. Pass a client-side placeholder that the server's real value reconciles a moment later.

// createdAt is undefined until reconcile: new Date(undefined) is Invalid Date
comments.insert({ text: form.text, author: session.get("userName")! });

// pass a client-side placeholder; the server's now() reconciles it
comments.insert({
  text: form.text,
  author: session.get("userName")!,
  createdAt: new Date(),
});