Patterns Medium
Optimistic UI
Optimistic mutation pattern for list add and remove actions with rollback on simulated failures.
Open in Lab
MCP
vanilla-js css react vue svelte
Targets: TS JS HTML React Vue Svelte
Code
* {
box-sizing: border-box;
}
:root {
--bg: #060a13;
--panel: #111827;
--text: #e2e8f0;
--muted: #94a3b8;
--border: rgba(255, 255, 255, 0.14);
--accent: #22d3ee;
--danger: #fb7185;
}
body {
margin: 0;
background: linear-gradient(165deg, #0f1c35, var(--bg));
color: var(--text);
font-family: "Sora", system-ui, sans-serif;
}
.shell {
width: min(640px, calc(100% - 2rem));
margin: 2rem auto;
}
h1 {
margin-bottom: 0.35rem;
}
p {
margin-top: 0;
color: var(--muted);
}
.task-form {
display: flex;
gap: 0.6rem;
margin-bottom: 0.8rem;
}
.task-form input {
flex: 1;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--panel);
color: var(--text);
padding: 0.6rem;
}
.task-form button {
border: 0;
border-radius: 10px;
padding: 0.6rem 0.9rem;
font-weight: 700;
background: var(--accent);
color: #032026;
}
.status {
min-height: 1.25rem;
margin: 0 0 0.8rem;
font-size: 0.85rem;
}
.tasks {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 0.6rem;
}
.tasks li {
border: 1px solid var(--border);
border-radius: 12px;
background: var(--panel);
padding: 0.75rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.7rem;
}
.task-left {
display: flex;
align-items: center;
gap: 0.55rem;
}
.pending {
font-size: 0.75rem;
padding: 0.1rem 0.4rem;
border-radius: 999px;
border: 1px solid #67e8f9;
color: #a5f3fc;
}
.tasks button {
border: 1px solid rgba(251, 113, 133, 0.4);
background: rgba(251, 113, 133, 0.12);
color: #fecdd3;
border-radius: 8px;
padding: 0.35rem 0.6rem;
}
.status.error {
color: var(--danger);
}
.status.ok {
color: #67e8f9;
}(() => {
let tasks = [
{ id: 1, label: "Ship release notes", pending: false },
{ id: 2, label: "Clean API logs", pending: false },
];
const list = document.getElementById("tasks");
const form = document.getElementById("task-form");
const input = document.getElementById("task-input");
const status = document.getElementById("status");
let nextId = 3;
const setStatus = (message, tone = "ok") => {
status.textContent = message;
status.className = `status ${tone}`;
};
const fakeRequest = () =>
new Promise((resolve, reject) => {
const delay = 300 + Math.random() * 700;
setTimeout(() => {
if (Math.random() < 0.25) {
reject(new Error("Request failed"));
} else {
resolve(true);
}
}, delay);
});
const render = () => {
list.innerHTML = "";
for (const task of tasks) {
const li = document.createElement("li");
li.innerHTML = `
<div class="task-left">
<span>${task.label}</span>
${task.pending ? '<span class="pending">pending</span>' : ""}
</div>
<button type="button" data-delete="${task.id}">Delete</button>
`;
list.appendChild(li);
}
};
form.addEventListener("submit", async (event) => {
event.preventDefault();
const label = input.value.trim();
if (!label) return;
const tempId = -Date.now();
tasks = [{ id: tempId, label, pending: true }, ...tasks];
input.value = "";
setStatus("Task added optimistically...");
render();
try {
await fakeRequest();
tasks = tasks.map((task) => {
if (task.id !== tempId) return task;
return { id: nextId++, label: task.label, pending: false };
});
setStatus("Task confirmed by server.", "ok");
} catch (_error) {
tasks = tasks.filter((task) => task.id !== tempId);
setStatus("Add failed. Rolled back.", "error");
}
render();
});
list.addEventListener("click", async (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
const value = target.getAttribute("data-delete");
if (!value) return;
const id = Number(value);
const index = tasks.findIndex((task) => task.id === id);
if (index < 0) return;
const [removed] = tasks.splice(index, 1);
render();
setStatus("Task removed optimistically...");
try {
await fakeRequest();
setStatus("Deletion confirmed by server.", "ok");
} catch (_error) {
tasks.splice(index, 0, removed);
setStatus("Delete failed. Restored item.", "error");
render();
}
});
render();
})();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Optimistic UI</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="shell">
<h1>Optimistic UI</h1>
<p>Immediate UI updates with rollback when a request fails.</p>
<form id="task-form" class="task-form">
<input id="task-input" type="text" placeholder="Add task" aria-label="Add task" />
<button type="submit">Add</button>
</form>
<p id="status" class="status" role="status" aria-live="polite"></p>
<ul id="tasks" class="tasks"></ul>
</main>
<script src="script.js"></script>
</body>
</html>import { type FormEvent, useRef, useState } from "react";
type Task = {
id: number;
label: string;
pending: boolean;
};
type NoticeTone = "idle" | "info" | "ok" | "error";
const fakeRequest = () =>
new Promise<void>((resolve, reject) => {
const delay = 280 + Math.random() * 720;
setTimeout(() => {
if (Math.random() < 0.28) {
reject(new Error("Request failed"));
} else {
resolve();
}
}, delay);
});
export default function OptimisticUiPattern() {
const [tasks, setTasks] = useState<Task[]>([
{ id: 1, label: "Ship release notes", pending: false },
{ id: 2, label: "Clean API logs", pending: false },
]);
const [value, setValue] = useState("");
const [notice, setNotice] = useState<{ message: string; tone: NoticeTone }>({
message: "Use optimistic updates to keep interactions instant.",
tone: "info",
});
const nextIdRef = useRef(3);
const nextTempIdRef = useRef(-1);
const onAdd = async (event: FormEvent) => {
event.preventDefault();
const label = value.trim();
if (!label) {
setNotice({ message: "Enter a task name before adding.", tone: "error" });
return;
}
const tempId = nextTempIdRef.current;
nextTempIdRef.current -= 1;
setValue("");
setTasks((prev) => [{ id: tempId, label, pending: true }, ...prev]);
setNotice({ message: "Task added optimistically. Syncing with server...", tone: "info" });
try {
await fakeRequest();
const confirmedId = nextIdRef.current;
nextIdRef.current += 1;
setTasks((prev) =>
prev.map((task) =>
task.id === tempId ? { ...task, id: confirmedId, pending: false } : task
)
);
setNotice({ message: "Task confirmed by server.", tone: "ok" });
} catch {
setTasks((prev) => prev.filter((task) => task.id !== tempId));
setNotice({ message: "Add failed. Optimistic task was rolled back.", tone: "error" });
}
};
const onDelete = async (id: number) => {
let removed: Task | null = null;
let removedIndex = -1;
setTasks((prev) => {
removedIndex = prev.findIndex((task) => task.id === id);
if (removedIndex === -1) return prev;
removed = prev[removedIndex];
return prev.filter((task) => task.id !== id);
});
if (!removed) return;
const rollbackTask = removed;
const rollbackIndex = removedIndex;
setNotice({ message: "Task removed optimistically. Syncing with server...", tone: "info" });
try {
await fakeRequest();
setNotice({ message: "Deletion confirmed by server.", tone: "ok" });
} catch {
setTasks((prev) => {
if (prev.some((task) => task.id === rollbackTask.id)) return prev;
const next = prev.slice();
const safeIndex = Math.min(Math.max(rollbackIndex, 0), next.length);
next.splice(safeIndex, 0, rollbackTask);
return next;
});
setNotice({ message: "Delete failed. Item restored.", tone: "error" });
}
};
const noticeClass =
notice.tone === "ok"
? "text-emerald-300"
: notice.tone === "error"
? "text-rose-300"
: "text-sky-300";
return (
<section className="min-h-screen bg-[#0d1117] px-4 py-6 text-[#e6edf3]">
<div className="mx-auto max-w-2xl space-y-4">
<header className="rounded-2xl border border-[#30363d] bg-[#161b22] p-4">
<p className="text-xs font-bold uppercase tracking-wide text-[#8b949e]">Pattern</p>
<h1 className="mt-1 text-lg font-bold">Optimistic UI</h1>
<p className="mt-1 text-sm text-[#8b949e]">
UI updates immediately and rolls back only if the request fails.
</p>
</header>
<form
onSubmit={onAdd}
className="flex gap-2 rounded-2xl border border-[#30363d] bg-[#161b22] p-3"
>
<input
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder="Add task"
className="flex-1 rounded-lg border border-[#30363d] bg-[#0d1117] px-3 py-2 text-sm text-[#e6edf3] placeholder-[#6b7280] outline-none transition-colors focus:border-[#58a6ff]"
/>
<button
type="submit"
className="rounded-lg border border-[#58a6ff]/45 bg-[#58a6ff]/15 px-4 py-2 text-sm font-semibold text-[#c9e6ff] transition-colors hover:bg-[#58a6ff]/25"
>
Add
</button>
</form>
<p className={`min-h-5 text-xs ${noticeClass}`}>{notice.message}</p>
<ul className="grid gap-2">
{tasks.map((task) => (
<li
key={task.id}
className="flex items-center justify-between gap-3 rounded-xl border border-[#30363d] bg-[#161b22] px-3 py-2.5"
>
<div className="flex items-center gap-2 text-sm">
<span className={task.pending ? "text-[#cdd9e5]" : "text-[#e6edf3]"}>
{task.label}
</span>
{task.pending && (
<span className="rounded-full border border-sky-300/30 bg-sky-400/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-sky-200">
syncing
</span>
)}
</div>
<button
type="button"
disabled={task.pending}
onClick={() => onDelete(task.id)}
className="rounded-md border border-rose-500/35 bg-rose-500/10 px-2.5 py-1 text-xs font-semibold text-rose-300 transition-colors enabled:hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50"
>
Delete
</button>
</li>
))}
{tasks.length === 0 && (
<li className="rounded-xl border border-dashed border-[#30363d] bg-[#161b22] px-3 py-5 text-center text-sm text-[#8b949e]">
No tasks left. Add one to test optimistic create.
</li>
)}
</ul>
</div>
</section>
);
}<script setup>
import { ref, computed } from "vue";
const tasks = ref([
{ id: 1, label: "Ship release notes", pending: false },
{ id: 2, label: "Clean API logs", pending: false },
]);
const value = ref("");
const notice = ref({
message: "Use optimistic updates to keep interactions instant.",
tone: "info",
});
let nextId = 3;
let nextTempId = -1;
function fakeRequest() {
return new Promise((resolve, reject) => {
const delay = 280 + Math.random() * 720;
setTimeout(() => {
if (Math.random() < 0.28) reject(new Error("Request failed"));
else resolve();
}, delay);
});
}
async function onAdd() {
const label = value.value.trim();
if (!label) {
notice.value = { message: "Enter a task name before adding.", tone: "error" };
return;
}
const tempId = nextTempId;
nextTempId -= 1;
value.value = "";
tasks.value = [{ id: tempId, label, pending: true }, ...tasks.value];
notice.value = { message: "Task added optimistically. Syncing with server...", tone: "info" };
try {
await fakeRequest();
const confirmedId = nextId;
nextId += 1;
tasks.value = tasks.value.map((t) =>
t.id === tempId ? { ...t, id: confirmedId, pending: false } : t
);
notice.value = { message: "Task confirmed by server.", tone: "ok" };
} catch {
tasks.value = tasks.value.filter((t) => t.id !== tempId);
notice.value = { message: "Add failed. Optimistic task was rolled back.", tone: "error" };
}
}
async function onDelete(id) {
const removedIndex = tasks.value.findIndex((t) => t.id === id);
if (removedIndex === -1) return;
const removed = tasks.value[removedIndex];
tasks.value = tasks.value.filter((t) => t.id !== id);
notice.value = { message: "Task removed optimistically. Syncing with server...", tone: "info" };
try {
await fakeRequest();
notice.value = { message: "Deletion confirmed by server.", tone: "ok" };
} catch {
if (!tasks.value.some((t) => t.id === removed.id)) {
const next = [...tasks.value];
const safeIndex = Math.min(Math.max(removedIndex, 0), next.length);
next.splice(safeIndex, 0, removed);
tasks.value = next;
}
notice.value = { message: "Delete failed. Item restored.", tone: "error" };
}
}
const noticeClass = computed(() =>
notice.value.tone === "ok"
? "text-emerald-300"
: notice.value.tone === "error"
? "text-rose-300"
: "text-sky-300"
);
</script>
<template>
<section class="min-h-screen bg-[#0d1117] px-4 py-6 text-[#e6edf3]">
<div class="mx-auto max-w-2xl space-y-4">
<header class="rounded-2xl border border-[#30363d] bg-[#161b22] p-4">
<p class="text-xs font-bold uppercase tracking-wide text-[#8b949e]">Pattern</p>
<h1 class="mt-1 text-lg font-bold">Optimistic UI</h1>
<p class="mt-1 text-sm text-[#8b949e]">
UI updates immediately and rolls back only if the request fails.
</p>
</header>
<form
@submit.prevent="onAdd"
class="flex gap-2 rounded-2xl border border-[#30363d] bg-[#161b22] p-3"
>
<input
v-model="value"
placeholder="Add task"
class="flex-1 rounded-lg border border-[#30363d] bg-[#0d1117] px-3 py-2 text-sm text-[#e6edf3] placeholder-[#6b7280] outline-none transition-colors focus:border-[#58a6ff]"
/>
<button
type="submit"
class="rounded-lg border border-[#58a6ff]/45 bg-[#58a6ff]/15 px-4 py-2 text-sm font-semibold text-[#c9e6ff] transition-colors hover:bg-[#58a6ff]/25"
>
Add
</button>
</form>
<p :class="['min-h-5 text-xs', noticeClass]">{{ notice.message }}</p>
<ul class="grid gap-2">
<li
v-for="task in tasks"
:key="task.id"
class="flex items-center justify-between gap-3 rounded-xl border border-[#30363d] bg-[#161b22] px-3 py-2.5"
>
<div class="flex items-center gap-2 text-sm">
<span :class="task.pending ? 'text-[#cdd9e5]' : 'text-[#e6edf3]'">{{ task.label }}</span>
<span v-if="task.pending" class="rounded-full border border-sky-300/30 bg-sky-400/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-sky-200">
syncing
</span>
</div>
<button
type="button"
:disabled="task.pending"
@click="onDelete(task.id)"
class="rounded-md border border-rose-500/35 bg-rose-500/10 px-2.5 py-1 text-xs font-semibold text-rose-300 transition-colors enabled:hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50"
>
Delete
</button>
</li>
<li v-if="tasks.length === 0" class="rounded-xl border border-dashed border-[#30363d] bg-[#161b22] px-3 py-5 text-center text-sm text-[#8b949e]">
No tasks left. Add one to test optimistic create.
</li>
</ul>
</div>
</section>
</template><script>
let tasks = [
{ id: 1, label: "Ship release notes", pending: false },
{ id: 2, label: "Clean API logs", pending: false },
];
let value = "";
let notice = { message: "Use optimistic updates to keep interactions instant.", tone: "info" };
let nextId = 3;
let nextTempId = -1;
function fakeRequest() {
return new Promise((resolve, reject) => {
const delay = 280 + Math.random() * 720;
setTimeout(() => {
if (Math.random() < 0.28) reject(new Error("Request failed"));
else resolve();
}, delay);
});
}
async function onAdd(event) {
event.preventDefault();
const label = value.trim();
if (!label) {
notice = { message: "Enter a task name before adding.", tone: "error" };
return;
}
const tempId = nextTempId;
nextTempId -= 1;
value = "";
tasks = [{ id: tempId, label, pending: true }, ...tasks];
notice = { message: "Task added optimistically. Syncing with server...", tone: "info" };
try {
await fakeRequest();
const confirmedId = nextId;
nextId += 1;
tasks = tasks.map((t) => (t.id === tempId ? { ...t, id: confirmedId, pending: false } : t));
notice = { message: "Task confirmed by server.", tone: "ok" };
} catch {
tasks = tasks.filter((t) => t.id !== tempId);
notice = { message: "Add failed. Optimistic task was rolled back.", tone: "error" };
}
}
async function onDelete(id) {
const removedIndex = tasks.findIndex((t) => t.id === id);
if (removedIndex === -1) return;
const removed = tasks[removedIndex];
tasks = tasks.filter((t) => t.id !== id);
notice = { message: "Task removed optimistically. Syncing with server...", tone: "info" };
try {
await fakeRequest();
notice = { message: "Deletion confirmed by server.", tone: "ok" };
} catch {
if (!tasks.some((t) => t.id === removed.id)) {
const next = [...tasks];
const safeIndex = Math.min(Math.max(removedIndex, 0), next.length);
next.splice(safeIndex, 0, removed);
tasks = next;
}
notice = { message: "Delete failed. Item restored.", tone: "error" };
}
}
$: noticeClass =
notice.tone === "ok"
? "text-emerald-300"
: notice.tone === "error"
? "text-rose-300"
: "text-sky-300";
</script>
<section class="min-h-screen bg-[#0d1117] px-4 py-6 text-[#e6edf3]">
<div class="mx-auto max-w-2xl space-y-4">
<header class="rounded-2xl border border-[#30363d] bg-[#161b22] p-4">
<p class="text-xs font-bold uppercase tracking-wide text-[#8b949e]">Pattern</p>
<h1 class="mt-1 text-lg font-bold">Optimistic UI</h1>
<p class="mt-1 text-sm text-[#8b949e]">
UI updates immediately and rolls back only if the request fails.
</p>
</header>
<form
on:submit|preventDefault={onAdd}
class="flex gap-2 rounded-2xl border border-[#30363d] bg-[#161b22] p-3"
>
<input
bind:value={value}
placeholder="Add task"
class="flex-1 rounded-lg border border-[#30363d] bg-[#0d1117] px-3 py-2 text-sm text-[#e6edf3] placeholder-[#6b7280] outline-none transition-colors focus:border-[#58a6ff]"
/>
<button
type="submit"
class="rounded-lg border border-[#58a6ff]/45 bg-[#58a6ff]/15 px-4 py-2 text-sm font-semibold text-[#c9e6ff] transition-colors hover:bg-[#58a6ff]/25"
>
Add
</button>
</form>
<p class="min-h-5 text-xs {noticeClass}">{notice.message}</p>
<ul class="grid gap-2">
{#each tasks as task (task.id)}
<li class="flex items-center justify-between gap-3 rounded-xl border border-[#30363d] bg-[#161b22] px-3 py-2.5">
<div class="flex items-center gap-2 text-sm">
<span class={task.pending ? "text-[#cdd9e5]" : "text-[#e6edf3]"}>{task.label}</span>
{#if task.pending}
<span class="rounded-full border border-sky-300/30 bg-sky-400/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-sky-200">
syncing
</span>
{/if}
</div>
<button
type="button"
disabled={task.pending}
on:click={() => onDelete(task.id)}
class="rounded-md border border-rose-500/35 bg-rose-500/10 px-2.5 py-1 text-xs font-semibold text-rose-300 transition-colors enabled:hover:bg-rose-500/20 disabled:cursor-not-allowed disabled:opacity-50"
>
Delete
</button>
</li>
{/each}
{#if tasks.length === 0}
<li class="rounded-xl border border-dashed border-[#30363d] bg-[#161b22] px-3 py-5 text-center text-sm text-[#8b949e]">
No tasks left. Add one to test optimistic create.
</li>
{/if}
</ul>
</div>
</section>Optimistic UI
A resilient pattern for fast-feeling interfaces where UI updates immediately before server confirmation.
Features
- Immediate optimistic insertion and deletion
- Pending state tags
- Simulated network latency
- Automatic rollback on failure with status message