Recipe: Optimistic updates + rollback

Like-button list where the click flips the UI immediately and the fetch runs in the background. If the request fails, state is restored from a snapshot taken before the optimistic write and a toast explains what happened.

snapshot + rollback bus toast no race window

Live preview

The mock backend fails ~30% of the time. Watch the heart snap back when it does.

Full source

<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">
        ♥ <span data-text="item.likes"></span>
      </button>
    </div>
  </template>
</div>
Micra.define('post-list', {
  state: { items: [/* … */] },

  async toggleLike(e) {
    const id       = e.currentTarget.dataset.id
    const snapshot = this.state.items                          // freeze for rollback

    // 1. Optimistic write — 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 explain.
      this.state.items = snapshot
      Micra.emit('toast', {
        title: 'Could not save',
        message: 'Restored your last state.',
        severity: 'error',
      })
    }
  },
})

Why a snapshot, not a flag?

The naive approach — track pendingId and reverse the mutation on failure — works for one field but breaks the moment you optimistically touch two values (here: liked AND likes). The snapshot pattern is independent of how many fields you change, and rolling back is one line:

this.state.items = snapshot   // back to the pre-click world

Because items is a top-level key, replacing it triggers a single re-render — Micra's keyed data-each diffs the snapshot against the optimistic version and updates only the affected row.

Concurrent clicks on the same row

If the user double-clicks while the first request is in flight, each click captures its own snapshot — the second snapshot already includes the first optimistic write. On failure of the second request, you roll back to the state that includes the first (still-pending) change, which is what the user intuitively expects.

If you want 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)
  }
}

Pitfalls

  • Don't await the fetch before the optimistic write. That defeats the whole point — the user waits on the network just like before. Mutate first, fetch second.
  • Don't snapshot this.state.items by reference and then mutate it. snapshot = this.state.items is fine because the next write (this.state.items = …new array…) replaces the array — the snapshot still points at the old one. If you ever did items.push(...), you'd lose the snapshot. Replace, never mutate.
  • Don't roll back silently. Always emit feedback (toast / inline error). A reverting heart with no explanation looks like the click missed.
  • Don't trust the rolled-back row to match the server. The server's view may have moved on between rollback and your next read. Treat rollback as a "last known good" approximation and refresh from the server on the next list load.