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.
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.