Recipe: Optimistic updates with rollback
Apply the change to the UI immediately, send the request in the background, and
if it fails, restore a snapshot of the pre-click state. Because the list is
a top-level key, both the optimistic write and the rollback are single
assignments — and the keyed data-each only re-renders the row that changed.
Markup
<div data-component="post-list">
<template data-each="items" data-key="id">
<div class="post-card">
<div>
<strong data-text="item.title"></strong>
<p data-text="item.snippet"></p>
</div>
<button @click="toggleLike" data-bind="data-id:item.id" data-class="liked:item.liked">
Like (<span data-text="item.likes"></span>)
</button>
</div>
</template>
</div>
Component
Micra.define("post-list", {
state: { items: [/* { id, title, snippet, liked, likes } … */] },
async toggleLike(e) {
const id = e.currentTarget.dataset.id;
const snapshot = this.state.items; // freeze for rollback
// 1. Optimistic write — the UI updates immediately.
this.state.items = this.state.items.map((p) =>
p.id === id
? { ...p, liked: !p.liked, likes: p.likes + (p.liked ? -1 : 1) }
: p,
);
// 2. Fire the request in the background.
try {
await this.fetch("/api/posts/" + id + "/like", { method: "POST" });
} catch {
// 3. Roll back to the snapshot and tell the user.
this.state.items = snapshot;
Micra.emit("toast", {
title: "Could not save",
message: "Restored your last state.",
severity: "error",
});
}
},
});
Why a snapshot, not a flag?
Tracking a pendingId and reversing the mutation on failure works for one field
but breaks the moment you touch two (here liked and likes). A snapshot
is independent of how many fields change, and rollback is one line:
this.state.items = snapshot; // back to the pre-click world
Since items is a top-level key, replacing it is a single re-render — the keyed
data-each diffs the snapshot against the optimistic version and updates only
the affected row.
Concurrent clicks on the same row
Each click captures its own snapshot, so a double-click rolls back to the state that includes the first (still-pending) change — what the user expects. To prevent overlap entirely, gate on a per-id pending set:
async toggleLike(e) {
const id = e.currentTarget.dataset.id;
if (this._pending?.has(id)) return; // already in flight
this._pending = (this._pending || new Set()).add(id);
try {
/* … optimistic + fetch as above … */
} finally {
this._pending.delete(id);
}
}
Notes
- The rollback uses the event bus (
Micra.emit("toast", …)) to surface the failure — any toast component listening viaMicra.on("toast", …)shows it. Drop that line if you don’t have one. data-class="liked:item.liked"toggles styling straight from the data — no separate “liked” UI flag to keep in sync.