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