Optimistic UI Is Easy to Add and Hard to Get Right
Optimistic UI Is Easy to Add and Hard to Get Right
Implementation patterns in React Query and SWR, handling rollback gracefully, conflict resolution, and why most tutorials stop before the difficult part.
The Tutorial Version
Every optimistic UI tutorial looks like this:
// The "simple" version everyone shows
const { mutate } = useSWR('/api/todos');
async function addTodo(newTodo) {
// 1. Update UI immediately
mutate([...todos, newTodo], false);
// 2. Send request
await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(newTodo),
});
// 3. Revalidate
mutate();
}
Simple, right? The UI updates instantly. Users feel the speed. Ship it.
Then production happens:
- The request fails. The todo is still showing.
- The user adds three todos rapidly. Two fail.
- Another user deleted the list while this user was adding to it.
- The server assigns a different ID than the optimistic one.
- The user navigates away before the request completes.
- The server reorders items. The optimistic position was wrong.
This post is about everything after the tutorial ends.
What Optimistic UI Actually Requires
┌─────────────────────────────────────────────────────────────────┐
│ OPTIMISTIC UI REQUIREMENTS │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. IMMEDIATE FEEDBACK │
│ └── Update UI before server confirms │
│ │
│ 2. GRACEFUL ROLLBACK │
│ └── Revert to previous state on failure │
│ └── Handle partial failures in batch operations │
│ │
│ 3. ID RECONCILIATION │
│ └── Match optimistic IDs to server IDs │
│ └── Update references without flicker │
│ │
│ 4. CONFLICT RESOLUTION │
│ └── Handle concurrent modifications │
│ └── Merge or reject based on conflict type │
│ │
│ 5. STATE CONSISTENCY │
│ └── Maintain consistency across related queries │
│ └── Handle navigation during pending operations │
│ │
│ 6. USER COMMUNICATION │
│ └── Show pending state appropriately │
│ └── Explain failures clearly │
│ │
└─────────────────────────────────────────────────────────────────┘
The Foundation: Understanding Mutation Flow
Before diving into code, understand what happens during an optimistic mutation:
Timeline of an optimistic mutation:
t=0ms User clicks "Add Todo"
│
├── onMutate: Save previous state, update cache optimistically
│
t=1ms UI shows new todo (optimistic)
│
├── mutationFn: Request sent to server
│
t=0-∞ User continues interacting
│ ├── Might add more items
│ ├── Might edit the optimistic item
│ ├── Might navigate away
│ └── Might close the browser
│
t=200ms Server responds
│
├── onSuccess: Reconcile server response with optimistic state
│ OR
├── onError: Rollback to previous state, show error
│
t=201ms UI reflects final state
The challenge: the world changes between t=1ms and t=200ms.
React Query: The Complete Pattern
Basic Setup with Proper Rollback
// hooks/useTodos.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface Todo {
id: string;
title: string;
completed: boolean;
createdAt: string;
}
interface OptimisticTodo extends Todo {
_optimistic?: boolean;
}
export function useAddTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (newTodo: Omit<Todo, 'id' | 'createdAt'>) => {
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newTodo),
});
if (!response.ok) {
throw new Error('Failed to add todo');
}
return response.json() as Promise<Todo>;
},
onMutate: async (newTodo) => {
// 1. Cancel outgoing refetches (so they don't overwrite optimistic update)
await queryClient.cancelQueries({ queryKey: ['todos'] });
// 2. Snapshot previous value
const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);
// 3. Optimistically update cache
const optimisticTodo: OptimisticTodo = {
id: `temp-${Date.now()}`, // Temporary ID
...newTodo,
createdAt: new Date().toISOString(),
_optimistic: true,
};
queryClient.setQueryData<OptimisticTodo[]>(['todos'], (old) => [
...(old || []),
optimisticTodo,
]);
// 4. Return context for rollback
return { previousTodos, optimisticTodo };
},
onError: (error, variables, context) => {
// 5. Rollback on error
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos);
}
},
onSuccess: (serverTodo, variables, context) => {
// 6. Replace optimistic todo with server version
queryClient.setQueryData<Todo[]>(['todos'], (old) => {
if (!old) return [serverTodo];
return old.map((todo) =>
(todo as OptimisticTodo)._optimistic &&
todo.id === context?.optimisticTodo.id
? serverTodo
: todo
);
});
},
onSettled: () => {
// 7. Always refetch to ensure consistency
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
}
Displaying Optimistic State in UI
// components/TodoList.tsx
import { useQuery } from '@tanstack/react-query';
import { useAddTodo } from '../hooks/useTodos';
interface OptimisticTodo extends Todo {
_optimistic?: boolean;
}
export function TodoList() {
const { data: todos = [] } = useQuery<OptimisticTodo[]>({
queryKey: ['todos'],
queryFn: fetchTodos,
});
const addTodo = useAddTodo();
return (
<ul>
{todos.map((todo) => (
<TodoItem
key={todo.id}
todo={todo}
isPending={todo._optimistic}
/>
))}
</ul>
);
}
function TodoItem({ todo, isPending }: { todo: Todo; isPending?: boolean }) {
return (
<li
className={cn(
'flex items-center gap-2 p-2',
isPending && 'opacity-60' // Visual indicator of pending state
)}
>
<span>{todo.title}</span>
{isPending && (
<span className="text-xs text-gray-400">Saving...</span>
)}
</li>
);
}
SWR: The Complete Pattern
SWR has a different API but the same challenges:
// hooks/useTodos.ts
import useSWR, { useSWRConfig } from 'swr';
import useSWRMutation from 'swr/mutation';
interface Todo {
id: string;
title: string;
completed: boolean;
}
export function useTodos() {
return useSWR<Todo[]>('/api/todos', fetcher);
}
export function useAddTodo() {
const { mutate } = useSWRConfig();
return useSWRMutation(
'/api/todos',
async (url: string, { arg: newTodo }: { arg: Omit<Todo, 'id'> }) => {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newTodo),
});
if (!response.ok) throw new Error('Failed to add todo');
return response.json() as Promise<Todo>;
},
{
// Optimistic update
optimisticData: (currentData: Todo[] | undefined, { arg: newTodo }) => {
const optimisticTodo: Todo = {
id: `temp-${Date.now()}`,
...newTodo,
};
return [...(currentData || []), optimisticTodo];
},
// Rollback on error
rollbackOnError: true,
// Don't revalidate immediately (we handle it)
revalidate: false,
// After success, update with server response
populateCache: (serverTodo: Todo, currentData: Todo[] | undefined) => {
if (!currentData) return [serverTodo];
// Replace temp ID with server ID
return currentData.map((todo) =>
todo.id.startsWith('temp-') ? serverTodo : todo
);
},
// Then revalidate to ensure consistency
onSuccess: () => {
mutate('/api/todos');
},
}
);
}
The Hard Parts Begin Here
Problem 1: Multiple Rapid Mutations
Users don't wait. They click rapidly.
User adds "Todo A" → optimistic update
User adds "Todo B" → optimistic update (before A confirms)
"Todo A" fails → rollback... but what about B?
"Todo B" succeeds → ???
The naive rollback restores the state before A, which removes B too.
// Solution: Track mutations individually
interface PendingMutation {
id: string;
type: 'add' | 'update' | 'delete';
data: any;
previousData?: any;
status: 'pending' | 'success' | 'error';
}
function useMutationTracker() {
const [pending, setPending] = useState<Map<string, PendingMutation>>(new Map());
const addPending = (mutation: PendingMutation) => {
setPending((prev) => new Map(prev).set(mutation.id, mutation));
};
const updateStatus = (id: string, status: PendingMutation['status']) => {
setPending((prev) => {
const next = new Map(prev);
const mutation = next.get(id);
if (mutation) {
next.set(id, { ...mutation, status });
}
return next;
});
};
const removePending = (id: string) => {
setPending((prev) => {
const next = new Map(prev);
next.delete(id);
return next;
});
};
return { pending, addPending, updateStatus, removePending };
}
// Improved mutation with individual tracking
export function useAddTodo() {
const queryClient = useQueryClient();
const mutationIdRef = useRef(0);
return useMutation({
mutationFn: async (newTodo: Omit<Todo, 'id'>) => {
const response = await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(newTodo),
});
if (!response.ok) throw new Error('Failed');
return response.json();
},
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const mutationId = `mutation-${++mutationIdRef.current}`;
const tempId = `temp-${mutationId}`;
const optimisticTodo = {
id: tempId,
...newTodo,
_mutationId: mutationId,
_optimistic: true,
};
// Add to existing list (don't snapshot entire list)
queryClient.setQueryData<Todo[]>(['todos'], (old) => [
...(old || []),
optimisticTodo,
]);
return { mutationId, tempId };
},
onError: (error, variables, context) => {
// Only remove THIS mutation's optimistic item
queryClient.setQueryData<Todo[]>(['todos'], (old) =>
old?.filter((todo) => todo.id !== context?.tempId) || []
);
},
onSuccess: (serverTodo, variables, context) => {
// Replace THIS mutation's temp item with server version
queryClient.setQueryData<Todo[]>(['todos'], (old) =>
old?.map((todo) =>
todo.id === context?.tempId ? serverTodo : todo
) || []
);
},
});
}
Problem 2: ID Reconciliation
The server assigns IDs. Your optimistic update used a temp ID. Other parts of the app might reference that ID.
// The problem:
// User adds todo with temp ID "temp-123"
// User immediately clicks todo to view details
// URL becomes /todos/temp-123
// Server responds with real ID "abc-456"
// User is now on a broken URL
// Solution: ID reconciliation with redirects
export function useAddTodo() {
const queryClient = useQueryClient();
const router = useRouter();
return useMutation({
// ... mutation config ...
onSuccess: (serverTodo, variables, context) => {
const tempId = context?.tempId;
const realId = serverTodo.id;
// 1. Update cache
queryClient.setQueryData<Todo[]>(['todos'], (old) =>
old?.map((todo) => (todo.id === tempId ? serverTodo : todo)) || []
);
// 2. Update any detail queries
queryClient.setQueryData(['todo', tempId], serverTodo);
queryClient.setQueryData(['todo', realId], serverTodo);
// 3. Update URL if needed
const currentPath = window.location.pathname;
if (currentPath.includes(tempId)) {
router.replace(currentPath.replace(tempId, realId), { scroll: false });
}
// 4. Publish event for other components
eventBus.emit('todo:idReconciled', { tempId, realId });
},
});
}
// Components that reference IDs need to handle temp IDs
function TodoDetail({ id }: { id: string }) {
const [resolvedId, setResolvedId] = useState(id);
useEffect(() => {
// Listen for ID reconciliation
const unsubscribe = eventBus.on('todo:idReconciled', ({ tempId, realId }) => {
if (id === tempId) {
setResolvedId(realId);
}
});
return unsubscribe;
}, [id]);
const { data: todo } = useQuery({
queryKey: ['todo', resolvedId],
queryFn: () => fetchTodo(resolvedId),
// Don't fetch if it's a temp ID
enabled: !resolvedId.startsWith('temp-'),
});
if (resolvedId.startsWith('temp-')) {
// Show optimistic data from list cache
const todos = queryClient.getQueryData<Todo[]>(['todos']);
const optimisticTodo = todos?.find((t) => t.id === resolvedId);
return <TodoView todo={optimisticTodo} isPending />;
}
return <TodoView todo={todo} />;
}
Problem 3: Dependent Data Updates
Adding a todo might affect multiple queries:
// Data dependencies that need updating:
// 1. The todos list
['todos']
// 2. Filtered/sorted variants
['todos', { filter: 'active' }]
['todos', { filter: 'completed' }]
['todos', { sort: 'date' }]
// 3. Counts and aggregations
['todoCount']
['todosStats']
// 4. Parent entities
['project', projectId] // if todo belongs to project
// 5. Search results (if todo matches current search)
['search', currentQuery]
// Solution: Centralized cache update logic
function updateTodoInAllQueries(
queryClient: QueryClient,
update: (todos: Todo[]) => Todo[]
) {
// Get all todo-related query keys
const todoQueries = queryClient.getQueriesData<Todo[]>({
queryKey: ['todos'],
});
// Update each one
for (const [queryKey] of todoQueries) {
queryClient.setQueryData<Todo[]>(queryKey, (old) => {
if (!old) return old;
return update(old);
});
}
// Update counts
queryClient.setQueryData<number>(['todoCount'], (old) => {
const todos = queryClient.getQueryData<Todo[]>(['todos']);
return todos?.length ?? old ?? 0;
});
}
// Usage in mutation
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const optimisticTodo = { id: `temp-${Date.now()}`, ...newTodo };
updateTodoInAllQueries(queryClient, (todos) => [...todos, optimisticTodo]);
return { optimisticTodo };
};
Problem 4: Server Returns Different Data
Your optimistic update assumed one thing. The server did another.
// You optimistically added:
{
id: 'temp-1',
title: 'Buy milk',
completed: false,
position: 5 // You assumed position 5
}
// Server returns:
{
id: 'abc-123',
title: 'Buy milk',
completed: false,
position: 3, // Server reordered!
createdBy: 'user-456', // Server added fields
createdAt: '2024-01-15T10:30:00Z'
}
// Solution: Always prefer server data, animate the difference
export function useAddTodo() {
return useMutation({
// ...
onSuccess: (serverTodo, variables, context) => {
queryClient.setQueryData<Todo[]>(['todos'], (old) => {
if (!old) return [serverTodo];
// 1. Remove optimistic version
const withoutOptimistic = old.filter(
(todo) => todo.id !== context?.tempId
);
// 2. Add server version at correct position
const sorted = [...withoutOptimistic, serverTodo].sort(
(a, b) => a.position - b.position
);
return sorted;
});
// 3. If position changed significantly, notify user
const positionDiff = Math.abs(
serverTodo.position - (context?.expectedPosition ?? 0)
);
if (positionDiff > 2) {
toast('Item was reordered by the server');
}
},
});
}
// Animate position changes in UI
function TodoList() {
const { data: todos } = useTodos();
return (
<Reorder.Group
axis="y"
values={todos}
onReorder={() => {}} // Controlled by data
layoutScroll
>
{todos.map((todo) => (
<Reorder.Item
key={todo.id}
value={todo}
layout
transition={{ duration: 0.2 }}
>
<TodoItem todo={todo} />
</Reorder.Item>
))}
</Reorder.Group>
);
}
Conflict Resolution
The hardest part of optimistic UI: what happens when two users edit the same thing?
Conflict Detection
// Server response indicating conflict
interface ConflictError {
type: 'CONFLICT';
code: 'VERSION_MISMATCH' | 'DELETED' | 'CONCURRENT_EDIT';
serverVersion: Todo;
clientVersion: Todo;
conflictedFields?: string[];
}
async function updateTodo(id: string, updates: Partial<Todo>, version: number) {
const response = await fetch(`/api/todos/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...updates, expectedVersion: version }),
});
if (response.status === 409) {
const conflict: ConflictError = await response.json();
throw conflict;
}
if (!response.ok) {
throw new Error('Update failed');
}
return response.json();
}
Conflict Resolution Strategies
// Strategy 1: Last Write Wins (simple, sometimes wrong)
onError: (error, variables, context) => {
if (error.type === 'CONFLICT') {
// Just accept server version
queryClient.setQueryData(['todos'], (old) =>
old?.map((t) => (t.id === error.serverVersion.id ? error.serverVersion : t))
);
toast('Your changes were overwritten by another user');
} else {
// Regular rollback
queryClient.setQueryData(['todos'], context?.previousTodos);
}
};
// Strategy 2: Client Wins (force update)
onError: async (error, variables, context) => {
if (error.type === 'CONFLICT' && error.code === 'VERSION_MISMATCH') {
// Retry with force flag
await updateTodo(variables.id, variables.updates, error.serverVersion.version, {
force: true,
});
}
};
// Strategy 3: Merge (field-level resolution)
onError: (error, variables, context) => {
if (error.type === 'CONFLICT') {
const merged = mergeChanges(
error.serverVersion, // Base: what server has
context.originalTodo, // Ours: what we started with
variables.updates // Theirs: what we tried to change
);
// Apply merged result
updateTodo(merged.id, merged, error.serverVersion.version);
}
};
function mergeChanges(server: Todo, original: Todo, clientChanges: Partial<Todo>): Todo {
const merged = { ...server };
for (const [key, clientValue] of Object.entries(clientChanges)) {
const serverValue = server[key];
const originalValue = original[key];
// If server changed this field too, we have a conflict
if (serverValue !== originalValue) {
// Server wins for this field (or show UI to resolve)
continue;
}
// Server didn't change this field, our change wins
merged[key] = clientValue;
}
return merged;
}
User-Driven Conflict Resolution
// Strategy 4: Ask the user
function useUpdateTodo() {
const queryClient = useQueryClient();
const [conflict, setConflict] = useState<ConflictError | null>(null);
const mutation = useMutation({
mutationFn: updateTodo,
onError: (error) => {
if (error.type === 'CONFLICT') {
// Don't auto-resolve, show UI
setConflict(error);
}
},
});
const resolveConflict = async (resolution: 'keep-mine' | 'keep-theirs' | 'merge') => {
if (!conflict) return;
switch (resolution) {
case 'keep-mine':
await mutation.mutateAsync({
...conflict.clientVersion,
version: conflict.serverVersion.version,
force: true,
});
break;
case 'keep-theirs':
queryClient.setQueryData(['todos'], (old) =>
old?.map((t) =>
t.id === conflict.serverVersion.id ? conflict.serverVersion : t
)
);
break;
case 'merge':
// Show merge UI
break;
}
setConflict(null);
};
return { mutation, conflict, resolveConflict };
}
// Conflict resolution UI
function ConflictDialog({ conflict, onResolve }) {
return (
<Dialog open={!!conflict}>
<DialogContent>
<DialogTitle>Conflict Detected</DialogTitle>
<div className="grid grid-cols-2 gap-4">
<div>
<h3>Your Version</h3>
<pre>{JSON.stringify(conflict.clientVersion, null, 2)}</pre>
</div>
<div>
<h3>Server Version</h3>
<pre>{JSON.stringify(conflict.serverVersion, null, 2)}</pre>
<p className="text-sm text-gray-500">
Modified by {conflict.serverVersion.modifiedBy}
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onResolve('keep-theirs')}>
Keep Their Changes
</Button>
<Button variant="outline" onClick={() => onResolve('keep-mine')}>
Keep My Changes
</Button>
<Button onClick={() => onResolve('merge')}>
Review & Merge
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
Handling Edge Cases
Navigation During Pending Mutation
// Problem: User navigates away before mutation completes
// Solution 1: Warn before navigation
function usePendingMutationGuard() {
const queryClient = useQueryClient();
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
const pendingMutations = queryClient.getMutationCache().getAll()
.filter((m) => m.state.status === 'pending');
if (pendingMutations.length > 0) {
e.preventDefault();
e.returnValue = 'You have unsaved changes';
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [queryClient]);
}
// Solution 2: Persist mutations for retry
import { persistQueryClient } from '@tanstack/react-query-persist-client';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
const persister = createSyncStoragePersister({
storage: window.localStorage,
});
// Mutations that were pending when user left will retry on return
persistQueryClient({
queryClient,
persister,
maxAge: 1000 * 60 * 60 * 24, // 24 hours
});
Offline Support
// Optimistic UI that works offline
import { onlineManager } from '@tanstack/react-query';
export function useAddTodo() {
return useMutation({
mutationFn: async (newTodo) => {
if (!onlineManager.isOnline()) {
// Store for later
await queueOfflineMutation('addTodo', newTodo);
// Return optimistic response
return {
...newTodo,
id: `offline-${Date.now()}`,
_offline: true,
};
}
return fetch('/api/todos', { /* ... */ });
},
onMutate: async (newTodo) => {
// Same optimistic update logic
},
// Retry when back online
retry: (failureCount, error) => {
if (error.message === 'OFFLINE') return true;
return failureCount < 3;
},
});
}
// Process queued mutations when online
onlineManager.subscribe((isOnline) => {
if (isOnline) {
processOfflineQueue();
}
});
Mutation Deduplication
// Problem: User double-clicks, sends same mutation twice
export function useAddTodo() {
const inflightRef = useRef<Set<string>>(new Set());
return useMutation({
mutationFn: async (newTodo) => {
// Create deterministic key for this mutation
const mutationKey = hashObject(newTodo);
if (inflightRef.current.has(mutationKey)) {
// Already in flight, skip
throw new Error('DUPLICATE');
}
inflightRef.current.add(mutationKey);
try {
return await fetch('/api/todos', { /* ... */ });
} finally {
inflightRef.current.delete(mutationKey);
}
},
onError: (error) => {
if (error.message === 'DUPLICATE') {
// Silently ignore duplicates
return;
}
// Handle other errors
},
// Or use React Query's built-in deduplication for mutations
mutationKey: ['addTodo'],
});
}
Rollback UX Patterns
Pattern 1: Subtle Revert
// Item quietly disappears with animation
function TodoItem({ todo, isPending }: TodoItemProps) {
return (
<motion.li
layout
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: isPending ? 0.6 : 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
>
{todo.title}
</motion.li>
);
}
// On rollback, AnimatePresence handles the exit animation
<AnimatePresence>
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</AnimatePresence>
Pattern 2: Error State with Retry
// Keep failed item visible with error state and retry option
interface TodoWithStatus extends Todo {
_status?: 'pending' | 'error' | 'synced';
_error?: string;
}
function TodoItem({ todo }: { todo: TodoWithStatus }) {
const retryMutation = useRetryMutation();
if (todo._status === 'error') {
return (
<li className="flex items-center gap-2 p-2 bg-red-50 border border-red-200 rounded">
<XCircle className="text-red-500" />
<span className="line-through text-red-700">{todo.title}</span>
<span className="text-sm text-red-600">{todo._error}</span>
<button
onClick={() => retryMutation.mutate(todo)}
className="ml-auto text-sm text-red-600 hover:text-red-800"
>
Retry
</button>
<button
onClick={() => dismissError(todo.id)}
className="text-sm text-gray-500 hover:text-gray-700"
>
Dismiss
</button>
</li>
);
}
return (/* normal rendering */);
}
Pattern 3: Toast-based Recovery
// Show undo option in toast
function useAddTodoWithUndo() {
const queryClient = useQueryClient();
const { toast, dismiss } = useToast();
return useMutation({
mutationFn: addTodo,
onMutate: async (newTodo) => {
// Standard optimistic update
const optimisticTodo = { id: `temp-${Date.now()}`, ...newTodo };
queryClient.setQueryData(['todos'], (old) => [...old, optimisticTodo]);
return { optimisticTodo };
},
onError: (error, variables, context) => {
// Remove optimistic item
queryClient.setQueryData(['todos'], (old) =>
old.filter((t) => t.id !== context.optimisticTodo.id)
);
// Show toast with retry
const toastId = toast({
title: 'Failed to add todo',
description: error.message,
action: (
<Button
variant="outline"
size="sm"
onClick={() => {
dismiss(toastId);
// Retry the mutation
mutation.mutate(variables);
}}
>
Retry
</Button>
),
});
},
});
}
Pattern 4: Inline Error Recovery
// Error state with inline editing to fix the issue
function AddTodoForm() {
const [value, setValue] = useState('');
const mutation = useAddTodo();
return (
<form
onSubmit={(e) => {
e.preventDefault();
mutation.mutate({ title: value });
}}
>
<input
value={value}
onChange={(e) => setValue(e.target.value)}
disabled={mutation.isPending}
className={cn(
mutation.isError && 'border-red-500 focus:ring-red-500'
)}
/>
{mutation.isError && (
<div className="mt-2 text-sm text-red-600">
<p>{mutation.error.message}</p>
<div className="mt-1 space-x-2">
<button
type="button"
onClick={() => mutation.reset()}
className="underline"
>
Clear error
</button>
<button
type="submit"
className="underline"
>
Try again
</button>
</div>
</div>
)}
</form>
);
}
Testing Optimistic UI
// Testing the happy path is easy. Testing edge cases is not.
describe('useAddTodo', () => {
it('shows optimistic update immediately', async () => {
const { result } = renderHook(() => useAddTodo());
act(() => {
result.current.mutate({ title: 'New todo' });
});
// Optimistic update should be in cache immediately
const todos = queryClient.getQueryData(['todos']);
expect(todos).toContainEqual(
expect.objectContaining({ title: 'New todo', _optimistic: true })
);
});
it('rolls back on error', async () => {
server.use(
rest.post('/api/todos', (req, res, ctx) => {
return res(ctx.status(500));
})
);
const initialTodos = [{ id: '1', title: 'Existing' }];
queryClient.setQueryData(['todos'], initialTodos);
const { result, waitFor } = renderHook(() => useAddTodo());
act(() => {
result.current.mutate({ title: 'New todo' });
});
// Wait for error
await waitFor(() => expect(result.current.isError).toBe(true));
// Should be rolled back
const todos = queryClient.getQueryData(['todos']);
expect(todos).toEqual(initialTodos);
});
it('handles rapid mutations correctly', async () => {
const { result, waitFor } = renderHook(() => useAddTodo());
// Rapid fire 3 mutations
act(() => {
result.current.mutate({ title: 'Todo 1' });
result.current.mutate({ title: 'Todo 2' });
result.current.mutate({ title: 'Todo 3' });
});
// All 3 should be in cache
let todos = queryClient.getQueryData(['todos']);
expect(todos).toHaveLength(3);
// Wait for all to complete
await waitFor(() => {
const mutations = queryClient.getMutationCache().getAll();
return mutations.every((m) => m.state.status === 'success');
});
// Still should have 3, with server IDs
todos = queryClient.getQueryData(['todos']);
expect(todos).toHaveLength(3);
expect(todos.every((t) => !t.id.startsWith('temp-'))).toBe(true);
});
it('handles conflict resolution', async () => {
server.use(
rest.patch('/api/todos/:id', (req, res, ctx) => {
return res(
ctx.status(409),
ctx.json({
type: 'CONFLICT',
code: 'VERSION_MISMATCH',
serverVersion: { id: '1', title: 'Server title', version: 2 },
})
);
})
);
// ... test conflict handling
});
});
Quick Reference
The Complete Mutation Lifecycle
useMutation({
mutationFn, // The actual API call
onMutate, // Before mutation: cancel queries, snapshot, optimistic update
onError, // On failure: rollback, show error
onSuccess, // On success: reconcile server data
onSettled, // Always: invalidate to ensure consistency
});
Optimistic Update Checklist
## Before Implementing
- [ ] What happens if the mutation fails?
- [ ] What happens if multiple mutations are in flight?
- [ ] What if the server returns different data than expected?
- [ ] What if another user modified the same data?
- [ ] What if the user navigates away?
- [ ] How will pending state be shown in UI?
## Implementation
- [ ] Cancel outgoing queries to prevent overwrites
- [ ] Snapshot previous state for rollback
- [ ] Use temporary IDs with clear prefix
- [ ] Mark optimistic items for UI differentiation
- [ ] Update ALL relevant queries, not just one
- [ ] Handle ID reconciliation on success
- [ ] Implement proper rollback on error
- [ ] Invalidate queries after settled
## UX
- [ ] Show pending state visually (opacity, spinner)
- [ ] Animate rollback/reorder smoothly
- [ ] Provide error recovery options (retry, dismiss)
- [ ] Consider offline support
- [ ] Test with slow networks (throttle to 3G)
When NOT to Use Optimistic UI
Skip optimistic updates when:
❌ The operation has significant side effects
(payment processing, sending emails)
❌ Failure is likely or common
(unreliable APIs, complex validation)
❌ The operation is irreversible
(delete with no undo)
❌ Multiple users edit the same data frequently
(real-time collaboration)
❌ Data consistency is critical
(financial transactions)
Use loading states instead for these cases.
Closing Thoughts
Optimistic UI is a UX decision, not just a technical one. It trades immediate feedback for implementation complexity and potential confusion when things go wrong.
The tutorial version — update cache, hope for the best — works for demos. Production requires thinking through every failure mode, every race condition, every user action that might happen while a request is in flight.
Before adding optimistic updates, ask: is the perceived performance gain worth the added complexity? Sometimes a well-designed loading state is the better choice.
When you do implement optimistic UI, remember: the hard part isn't making the UI fast. It's making it correct when things go wrong — and things will go wrong.
What did you think?