React Optimistic UI Updates: A Practical, Fail‑Safe Tutorial
Build fast, resilient optimistic UI updates in React. Learn patterns, rollback, and examples using plain hooks and TanStack Query.
Image used for representation purposes only.
What is an optimistic UI (and why it matters)
Optimistic UI updates render the result of a user action immediately—before the server confirms it—then reconcile with the real response. When the server succeeds, the UI is already correct; when it fails, you roll back or fix up the view.
Benefits:
- Perceived speed: interactions feel instant, even on slow or mobile networks.
- Flow preservation: users aren’t blocked waiting for spinners to finish.
- Higher completion: fewer abandoned actions due to latency frustration.
Trade‑offs:
- Complexity: you must manage rollbacks, deduplication, and race conditions.
- Consistency: the UI is temporarily ahead of the server; you need reliable reconciliation.
This tutorial walks through practical, fail‑safe patterns for optimistic updates in React—first with plain hooks, then with a data‑fetching library—plus production tips for errors, pagination, offline support, and testing.
When to (and not to) use optimistic updates
Use optimistic updates when:
- The action is frequent and latency-sensitive (toggling a like, adding a todo, reordering lists).
- The server success rate is high and validation is predictable.
- Conflicts are either rare or easily resolved on the client.
Avoid or be conservative when:
- Server-side validation regularly rejects the action (e.g., limited inventory).
- Conflicts are common and resolution is non-trivial (financial transactions, double-bookings).
- Security or authorization is uncertain (never assume success where permissions may fail).
Mental model: predict, render, reconcile
- Predict: Produce a plausible next state locally. Assign stable temporary IDs for new entities.
- Render: Update the UI immediately.
- Reconcile: When the server responds, confirm, replace temp IDs with real ones, or roll back and notify the user.
Key invariants:
- Every optimistic change must be revertible.
- Server is the source of truth; the cache/UI is eventually consistent with it.
- Responses may arrive out of order—guard against stale overwrites.
Baseline example with plain React hooks
We’ll implement a Todo list supporting create, toggle, and delete with optimistic updates and rollbacks.
Domain model
// types.ts
export type Todo = {
id: string; // real or temporary id
title: string;
completed: boolean;
optimistic?: boolean; // mark items created/changed optimistically
};
Simple API helpers
// api.ts
export async function createTodo(title: string) {
const res = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title })
});
if (!res.ok) throw new Error('Failed to create');
return (await res.json()) as { id: string; title: string; completed: boolean };
}
export async function toggleTodo(id: string, completed: boolean) {
const res = await fetch(`/api/todos/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed })
});
if (!res.ok) throw new Error('Failed to toggle');
return (await res.json()) as { id: string; completed: boolean };
}
export async function deleteTodo(id: string) {
const res = await fetch(`/api/todos/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete');
}
useOptimisticTodos hook
This hook applies the predict → render → reconcile pattern and guards against stale responses.
// useOptimisticTodos.tsx
import { useRef, useState } from 'react';
import { createTodo, toggleTodo, deleteTodo } from './api';
import type { Todo } from './types';
function tempId() {
// crypto.randomUUID is ideal; fallback for older browsers
try { return `temp-${crypto.randomUUID()}`; } catch { return `temp-${Date.now()}-${Math.random()}`; }
}
export function useOptimisticTodos(initial: Todo[] = []) {
const [todos, setTodos] = useState<Todo[]>(initial);
// Track a per-item version to ignore stale responses
const versions = useRef(new Map<string, number>());
const bump = (id: string) => versions.current.set(id, (versions.current.get(id) ?? 0) + 1);
const get = (id: string) => versions.current.get(id) ?? 0;
async function add(title: string) {
const id = tempId();
const optimistic: Todo = { id, title, completed: false, optimistic: true };
// Predict + render
setTodos(prev => [optimistic, ...prev]);
bump(id);
const v = get(id);
try {
const saved = await createTodo(title);
// Ignore if this optimistic item was removed/changed beyond this version
if (get(id) !== v) return;
setTodos(prev => prev.map(t => t.id === id ? { ...saved, optimistic: false } : t));
// Transfer version to real id for future edits
const oldV = versions.current.get(id) ?? 0;
versions.current.delete(id);
versions.current.set(saved.id, oldV);
} catch (e) {
// Roll back
setTodos(prev => prev.filter(t => t.id !== id));
// Surface error to caller for UI toast/snackbar
throw e;
}
}
async function toggle(id: string) {
const current = todos.find(t => t.id === id);
if (!current) return;
const prevCompleted = current.completed;
const nextCompleted = !prevCompleted;
// Predict + render
setTodos(prev => prev.map(t => t.id === id ? { ...t, completed: nextCompleted, optimistic: true } : t));
bump(id);
const v = get(id);
try {
await toggleTodo(id, nextCompleted);
if (get(id) !== v) return; // stale response, ignore
setTodos(prev => prev.map(t => t.id === id ? { ...t, optimistic: false } : t));
} catch (e) {
// Roll back
setTodos(prev => prev.map(t => t.id === id ? { ...t, completed: prevCompleted, optimistic: false } : t));
throw e;
}
}
async function remove(id: string) {
const snapshot = todos; // capture for rollback
// Predict + render
setTodos(prev => prev.filter(t => t.id !== id));
bump(id);
const v = get(id);
try {
await deleteTodo(id);
if (get(id) !== v) return;
// nothing else to do on success
} catch (e) {
// Roll back
setTodos(snapshot);
throw e;
}
}
return { todos, add, toggle, remove };
}
Wiring it up in a component
// Todos.tsx
import { useState } from 'react';
import { useOptimisticTodos } from './useOptimisticTodos';
export function Todos({ initial = [] }: { initial?: any[] }) {
const { todos, add, toggle, remove } = useOptimisticTodos(initial);
const [title, setTitle] = useState('');
const [error, setError] = useState<string | null>(null);
async function onAdd(e: React.FormEvent) {
e.preventDefault();
if (!title.trim()) return;
try {
await add(title.trim());
setTitle('');
} catch (err: any) {
setError(err?.message ?? 'Something went wrong');
}
}
return (
<div>
<form onSubmit={onAdd} style={{ display: 'flex', gap: 8 }}>
<input value={title} onChange={e => setTitle(e.target.value)} placeholder="New todo" />
<button type="submit">Add</button>
</form>
{error && (
<div role="status" aria-live="polite" style={{ color: 'crimson', marginTop: 8 }}>
{error}
</div>
)}
<ul style={{ marginTop: 12 }}>
{todos.map(t => (
<li key={t.id} style={{ opacity: t.optimistic ? 0.7 : 1 }}>
<label>
<input type="checkbox" checked={t.completed} onChange={() => toggle(t.id)} />
{t.title}
</label>
<button onClick={() => remove(t.id)} style={{ marginLeft: 8 }}>Delete</button>
</li>
))}
</ul>
</div>
);
}
Notes:
- Optimistic items render with slight opacity for subtle feedback.
- Role and aria-live ensure screen readers announce errors.
- versions Map guards against stale responses overwriting fresher UI state.
Using a data-fetching library (TanStack Query)
Libraries such as TanStack Query, SWR, Relay, or Apollo provide built-in primitives for optimistic updates. Here’s the canonical TanStack Query pattern using onMutate for prediction, and onError/onSuccess for reconciliation.
// TodosQuery.tsx
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { createTodo, toggleTodo, deleteTodo } from './api';
const key = ['todos'];
export function TodosQuery() {
const qc = useQueryClient();
const { data: todos = [] } = useQuery({ queryKey: key, queryFn: async () => (await fetch('/api/todos')).json() });
const addMutation = useMutation({
mutationFn: (title: string) => createTodo(title),
onMutate: async (title: string) => {
await qc.cancelQueries({ queryKey: key });
const previous = qc.getQueryData<any[]>(key) ?? [];
const temp = { id: `temp-${crypto.randomUUID?.() ?? Math.random()}`, title, completed: false, optimistic: true };
qc.setQueryData<any[]>(key, old => [temp, ...(old ?? [])]);
return { previous, tempId: temp.id };
},
onError: (_err, _vars, ctx) => {
if (ctx?.previous) qc.setQueryData(key, ctx.previous);
},
onSuccess: (saved, _vars, ctx) => {
qc.setQueryData<any[]>(key, old => old?.map(t => (t.id === ctx?.tempId ? { ...saved, optimistic: false } : t)) ?? []);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: key }); // final server truth
}
});
const toggleMutation = useMutation({
mutationFn: ({ id, completed }: { id: string; completed: boolean }) => toggleTodo(id, completed),
onMutate: async ({ id, completed }) => {
await qc.cancelQueries({ queryKey: key });
const previous = qc.getQueryData<any[]>(key) ?? [];
qc.setQueryData<any[]>(key, old => old?.map(t => (t.id === id ? { ...t, completed, optimistic: true } : t)) ?? []);
return { previous, id };
},
onError: (_err, _vars, ctx) => { if (ctx?.previous) qc.setQueryData(key, ctx.previous); },
onSettled: () => { qc.invalidateQueries({ queryKey: key }); }
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteTodo(id),
onMutate: async (id: string) => {
await qc.cancelQueries({ queryKey: key });
const previous = qc.getQueryData<any[]>(key) ?? [];
qc.setQueryData<any[]>(key, old => old?.filter(t => t.id !== id) ?? []);
return { previous };
},
onError: (_err, _vars, ctx) => { if (ctx?.previous) qc.setQueryData(key, ctx.previous); },
onSettled: () => { qc.invalidateQueries({ queryKey: key }); }
});
return (
<div>
<form onSubmit={e => { e.preventDefault(); const f = new FormData(e.currentTarget as HTMLFormElement); const title = String(f.get('title') || ''); if (title) addMutation.mutate(title); (e.currentTarget as HTMLFormElement).reset(); }}>
<input name="title" placeholder="New todo" />
<button type="submit">Add</button>
</form>
<ul style={{ marginTop: 12 }}>
{todos.map((t: any) => (
<li key={t.id} style={{ opacity: t.optimistic ? 0.7 : 1 }}>
<label>
<input type="checkbox" checked={t.completed} onChange={() => toggleMutation.mutate({ id: t.id, completed: !t.completed })} />
{t.title}
</label>
<button onClick={() => deleteMutation.mutate(t.id)} style={{ marginLeft: 8 }}>Delete</button>
</li>
))}
</ul>
</div>
);
}
Why this works well:
- onMutate synchronously updates the cache and returns context for rollbacks.
- onError reliably restores previous cache state.
- onSettled invalidates to confirm with server truth and clear any drift.
About React’s useOptimistic hook
Recent React releases include a useOptimistic hook intended for optimistic patterns, especially with server actions and forms. If your React version provides it, you can streamline prediction and rollback within a single hook. The concepts above still apply: predict locally, render, then reconcile or revert on failure. Consult your framework docs (e.g., Next.js App Router) for integration details.
Handling edge cases and pitfalls
- Temporary IDs: Always generate a stable temp id for new entities and replace it with the server id on success. Keep a map when needed.
- Out-of-order responses: Use per-entity versions or request timestamps to ignore stale writes.
- Duplicate clicks: Disable relevant controls while a specific entity is “optimistic,” or coalesce repeated actions.
- Undo UX: For destructive actions (delete, archive), show a toast with Undo. Defer the server call for a few seconds or send it but keep a reversible record.
- Validation failures: If the server rejects (e.g., title too long), roll back and keep the user’s original input focused with an inline message.
- Access control: Don’t assume success if an action may be unauthorized; roll back and route to sign-in or permissions UI.
- Realtime streams: If you also consume updates via WebSocket/SSE, de-duplicate server echoes of your own optimistic changes by comparing ids or clientMutationId.
Undo pattern example
// Defer deletion for 5s with an Undo window.
function useUndoableDelete(deleteFn: (id: string) => Promise<void>) {
const timers = useRef(new Map<string, number>());
const pending = useRef(new Set<string>());
async function schedule(id: string, commit: () => void, rollback: () => void) {
pending.current.add(id);
commit(); // optimistic remove now
const handle = window.setTimeout(async () => {
pending.current.delete(id);
try { await deleteFn(id); } catch { rollback(); }
}, 5000);
timers.current.set(id, handle);
}
function undo(id: string, rollback: () => void) {
const handle = timers.current.get(id);
if (handle) {
clearTimeout(handle);
timers.current.delete(id);
pending.current.delete(id);
rollback();
}
}
return { schedule, undo, isPending: (id: string) => pending.current.has(id) };
}
Pagination, sorting, and filtering
Optimistic changes must reflect wherever the entity appears.
- If you prepend newly created items to a paginated list, they might “disappear” when you paginate. Consider a logical “All items” cache alongside page caches, or invalidate all page queries after success.
- If a filter would exclude a just-toggled item (e.g., show: completed only), you may need to move it between filtered views immediately.
- For stable sort orders (by createdAt), predict a timestamp locally and refine after the server returns.
Offline and transient network errors
- Queue writes: Store pending mutations in IndexedDB/localForage, apply optimistic state, and flush when online. Mark items with pendingState = ‘created’ | ‘updated’ | ‘deleted’ and reconcile on success.
- Backoff and retry: Exponential backoff avoids overwhelming a flaky network. In TanStack Query, set retry and retryDelay; for manual hooks, implement a small retry policy.
- User feedback: Show a lightweight “Saving…” banner when many items are pending; degrade gracefully if offline for extended periods.
Testing optimistic flows
- Unit test reducers/hooks: Verify prediction, rollback, and reconciliation paths.
- Mock network timing: Use fake timers to simulate latency and reordering (resolve deletes before creates to shake out race issues).
- E2E checks: With Playwright or Cypress, assert that the UI updates immediately, then remains correct after the mocked server responds or fails.
Example with Jest/Vitest and React Testing Library:
it('optimistically adds then replaces temp id on success', async () => {
mockServer.post('/api/todos').respondAfter(50, { id: '42', title: 'A', completed: false });
render(<Todos initial={[]} />);
await user.type(screen.getByPlaceholderText('New todo'), 'A');
await user.click(screen.getByText('Add'));
// Immediately visible with temp id
expect(screen.getByText('A')).toBeInTheDocument();
// After server
await waitFor(() => expect(findTodoDomId('42')).toBeTruthy());
});
Accessibility and UX polish
- Announce changes: Use aria-live regions to announce success/failure. Keep messages concise.
- Subtle affordances: Dim optimistic items (opacity 0.7) or show a lightweight spinner badge on the row—not a blocking overlay.
- Undo discoverability: Position the Undo toast near the interaction origin; keep the window short to reduce conflicts (2–5 seconds).
Observability and safety nets
- Log error paths distinctly: optimistic_failure_rollback, optimistic_success_reconcile, optimistic_stale_ignored.
- Feature flag: Roll out optimistic updates per action and per cohort; keep a kill switch.
- Metrics: Track action latency (UI to server confirmation), rollback rate, and mismatch rate after invalidation.
Checklist for production
- Generate stable temporary IDs for new entities.
- Keep a revertible snapshot for each optimistic change.
- Guard against stale responses (per-entity version or timestamp).
- Handle duplicates and disable conflicting UI while pending.
- Show clear, accessible feedback; provide Undo for destructive actions.
- Invalidate/refetch after settlement to ensure server truth.
- Test latency, failure, and reordering thoroughly.
- Instrument rollbacks and maintain a feature flag.
Conclusion
Optimistic UI is one of the highest‑leverage performance techniques you can implement in React. By predicting a plausible result, rendering it immediately, and reconciling with server truth—plus guarding against failures and stale responses—you deliver a snappy, resilient experience. Start with the plain hooks approach to grasp the mechanics, then adopt a cache library like TanStack Query for a production‑grade implementation with fewer pitfalls and less boilerplate.
Related Posts
Build a Headless React UI Library: A Practical Tabs Tutorial
Build a headless React UI component library step by step—accessibility, state, TypeScript, testing, and packaging—using a fully featured Tabs example.
React Hydration Mismatch: A Practical Debugging Guide
Learn how to diagnose and fix React hydration mismatches with step-by-step checks, common causes, and production-safe patterns for SSR and Next.js.
React Server Actions for Form Handling: Patterns, Validation, and UX
Learn modern form handling with React Server Actions: validation, pending UI, redirects, file uploads, security, and testing—using practical Next.js examples.