Optimistic UI Toggle with Automatic Rollback on Failure — Correct State Management
Optimistic Action Button with Rollback on Failure · Loaders · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<div class="demo">
<div class="opt-card">
<div class="opt-row">
<div>
<span class="opt-title">Auto-renew subscription</span>
<span class="opt-sub" id="optSub">Currently enabled</span>
</div>
<button class="opt-toggle on" id="optToggle" role="switch" aria-checked="true" aria-label="Toggle auto-renew">
<span class="opt-knob"></span>
</button>
</div>
<div class="opt-log" id="optLog" role="status" aria-live="polite">Ready.</div>
<p class="opt-hint">Toggling simulates a network request that fails ~40% of the time — watch it roll back automatically on failure.</p>
</div>
</div>* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #f8fafc; display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 24px; }
.opt-card { width: 340px; max-width: 100%; background: #fff; border: 1px solid #e2e8f0; border-radius: 16px; padding: 20px; display: flex; flex-direction: column; gap: 14px; }
.opt-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.opt-title { display: block; font-size: 13.5px; font-weight: 700; color: #111827; }
.opt-sub { display: block; font-size: 11.5px; color: #94a3b8; font-weight: 600; margin-top: 2px; }
.opt-toggle { width: 46px; height: 27px; border-radius: 999px; background: #e2e8f0; border: none; position: relative; cursor: pointer; transition: background 0.2s; flex-shrink: 0; }
.opt-toggle.on { background: #34d399; }
.opt-toggle.pending { opacity: 0.7; cursor: wait; }
.opt-knob { position: absolute; top: 2px; left: 2px; width: 23px; height: 23px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.25); transition: transform 0.2s; display: flex; align-items: center; justify-content: center; }
.opt-toggle.on .opt-knob { transform: translateX(19px); }
.opt-toggle.pending .opt-knob::after { content: ''; width: 10px; height: 10px; border: 2px solid #cbd5e1; border-top-color: #6366f1; border-radius: 50%; animation: spin 0.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.opt-log { font-size: 11.5px; font-weight: 600; color: #64748b; background: #f8fafc; border-radius: 9px; padding: 9px 11px; min-height: 16px; }
.opt-log.error { color: #b91c1c; background: #fef2f2; }
.opt-log.success { color: #15803d; background: #f0fdf4; }
.opt-hint { font-size: 11px; color: #94a3b8; line-height: 1.5; }const toggle = document.getElementById('optToggle');
const subEl = document.getElementById('optSub');
const logEl = document.getElementById('optLog');
let serverState = true; // the "confirmed" state, as last acknowledged by the server
let pending = false;
function setLog(text, kind) {
logEl.textContent = text;
logEl.className = 'opt-log' + (kind ? ' ' + kind : '');
}
function renderToggle(isOn) {
toggle.classList.toggle('on', isOn);
toggle.setAttribute('aria-checked', String(isOn));
subEl.textContent = isOn ? 'Currently enabled' : 'Currently disabled';
}
// Simulates a real network request with a 40% failure rate and realistic latency.
function fakeApiCall(nextState) {
return new Promise((resolve, reject) => {
setTimeout(() => {
const willFail = Math.random() < 0.4;
if (willFail) reject(new Error('Network error — please try again.'));
else resolve(nextState);
}, 700 + Math.random() * 500);
});
}
toggle.addEventListener('click', async () => {
if (pending) return; // ignore clicks while a request is already in flight
const nextState = !serverState;
pending = true;
toggle.classList.add('pending');
toggle.disabled = true;
// Optimistic update: reflect the new state immediately, before the "server" confirms it.
renderToggle(nextState);
setLog('Saving…');
try {
const confirmed = await fakeApiCall(nextState);
serverState = confirmed;
setLog(`Saved — auto-renew is now ${confirmed ? 'on' : 'off'}.`, 'success');
} catch (err) {
// Roll back: revert the UI to the last confirmed server state, not just "the opposite".
renderToggle(serverState);
setLog(`${err.message} Reverted to previous state.`, 'error');
} finally {
pending = false;
toggle.classList.remove('pending');
toggle.disabled = false;
}
});Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Optimistic Action Button — Instant Feedback, Correct Rollback on Failure

Waiting for a server response before updating a toggle's visual state makes every click feel sluggish, even for actions that usually succeed. Optimistic UI updates the interface immediately, assuming success, and only reconciles with reality once the real request resolves — this snippet demonstrates the full pattern correctly, including the part most naive implementations get wrong: rolling back cleanly on failure.
Two separate concepts: serverState versus what's currently rendered
The code deliberately tracks serverState — the last state actually *confirmed* by the (simulated) server — separately from whatever the toggle currently displays. A click immediately renders the new desired state optimistically, but serverState isn't updated until the fake API call actually resolves successfully. This separation is exactly what makes correct rollback possible: on failure, the code can revert to serverState (the last known-good, confirmed value) rather than naively guessing "just flip it back," which would be wrong if, for example, a second click happened before the first request's rollback ran.
Why rollback reverts to serverState, not simply the opposite of the failed attempt
renderToggle(serverState) inside the catch block is a deliberate choice over something like renderToggle(!nextState). Reverting to the tracked confirmed state is robust even in edge cases — if serverState were somehow already different from what a naive "undo my last flip" calculation would assume, reverting to the actual last-confirmed truth is always correct, while reverting to "the opposite of what I just tried" is only correct by coincidence.
In-flight requests block new ones, rather than racing
if (pending) return; at the top of the click handler, combined with toggle.disabled = true for the request's duration, prevents a user from firing a second toggle request while the first is still resolving. Without this guard, two overlapping requests could resolve out of order, and whichever one's .then/.catch callback happened to run last would silently determine the final displayed state — a classic race condition this snippet avoids by simply not allowing overlapping requests in the first place.
Distinct visual and textual feedback for all three states
The toggle gets a .pending class (dimmed, a small spinner inside the knob, cursor: wait) while a request is in flight, and the status log line switches between neutral, .success, and .error styling depending on outcome — so a user always has both the toggle's own visual state and an explicit text explanation of what's currently happening, including the specific reason for a rollback when one occurs.
Step by step
How to Use
- 1Click the toggle repeatedly to see both outcomesAbout 60% of clicks succeed and confirm the new state; about 40% fail and visibly roll back with an error message.
- 2Replace fakeApiCall with a real requestSwap the setTimeout/Math.random simulation for an actual fetch() call, keeping the same resolve-on-success/reject-on-failure contract.
- 3Adjust the simulated failure rate or latencyChange the 0.4 probability or the 700 + Math.random() * 500 delay to model different network conditions during development.
- 4Reuse the pattern for other optimistic actionsThe same serverState-vs-optimistic-render structure applies to any toggle, like/unlike button, or single-value setting update.
- 5Customize the rollback messageEdit the error branch's setLog() call to surface a more specific or user-friendly failure explanation.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
The toggle's visual state (and the aria-checked attribute) update immediately when clicked, before the simulated network request has resolved — the interface assumes success up front rather than waiting for confirmation, which is the defining trait of optimistic UI.
serverState tracks the last value actually confirmed by the server, which is the only value guaranteed to be correct. Reverting to "whatever the toggle displayed right before this click" is a reasonable approximation most of the time, but reverting to the tracked confirmed truth is robust even in edge cases where that approximation could be wrong.
Nothing — the click handler checks a pending flag at the very top and returns immediately if a request is already running, and the toggle button is also natively disabled during that window, preventing a second overlapping request and the race condition it could cause.
Replace the body of fakeApiCall with an actual fetch() call (or another async request), keeping the same contract: resolve with the confirmed new state on success, and reject with an Error on failure — the rest of the click handler's logic works unchanged against that contract.
Being explicit about what happened (not just that something failed) helps a user understand the toggle's current displayed state is trustworthy again, rather than leaving them uncertain whether the failed action partially applied.
No — the same core structure (track a confirmed value separately from an optimistically-rendered one, guard against overlapping in-flight requests, roll back to the confirmed value on failure) applies to any single-value optimistic update: a star rating, a quantity stepper, an inline-edited text field, and more.