Mutations
elements man livetable/mutations Read as markdownInserting, 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(),
});