Users expect instant feedback. When someone clicks a like button, they want to see the heart fill immediately—not after a 200ms network round trip. Optimistic UI delivers this by updating the interface before the server confirms the action. If the server fails, the UI reverts. Done well, it makes apps feel fast and responsive.
How optimistic UI works
The pattern has three phases:
| Phase | What happens |
|---|---|
| Optimistic update | User acts, UI updates immediately |
| Server request | Request sent in background |
| Reconciliation | On success: keep UI state. On error: revert |
// Simplified flow
function handleLike() {
// 1. Update UI immediately
setLiked(true);
setLikeCount(count + 1);
// 2. Send request
try {
await likePost(postId);
// 3a. Success: UI already shows correct state
} catch (error) {
// 3b. Error: revert
setLiked(false);
setLikeCount(count);
showError('Failed to like post');
}
}
When to use optimistic updates
Optimistic UI works best for:
| Action type | Examples |
|---|---|
| Toggles | Like/unlike, follow/unfollow, bookmark |
| Additions | Add to cart, add comment, create item |
| Reordering | Drag-and-drop lists, priority changes |
| Updates | Edit text inline, change settings |
Avoid optimistic updates for:
| Action type | Why |
|---|---|
| Destructive actions | Delete account, remove shared data |
| Financial transactions | Payments, transfers |
| Complex validations | Server-side business rules |
| Idempotency concerns | Actions that can't be safely retried |
React 19 useOptimistic
React 19 introduces useOptimistic for this pattern:
'use client';
import { useOptimistic } from 'react';
import { likePost } from './actions';
interface Post {
id: string;
liked: boolean;
likeCount: number;
}
export function LikeButton({ post }: { post: Post }) {
const [optimisticPost, addOptimistic] = useOptimistic(
post,
(current, newLiked: boolean) => ({
...current,
liked: newLiked,
likeCount: current.likeCount + (newLiked ? 1 : -1),
})
);
async function handleClick() {
const newLiked = !optimisticPost.liked;
addOptimistic(newLiked);
try {
await likePost(post.id, newLiked);
} catch {
// React automatically reverts on error
}
}
return (
<button onClick={handleClick}>
{optimisticPost.liked ? '❤️' : '🤍'} {optimisticPost.likeCount}
</button>
);
}
The optimistic state resets when the action completes or errors.
Manual implementation with useState
Without useOptimistic, manage state manually:
'use client';
import { useState } from 'react';
export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
const [todos, setTodos] = useState(initialTodos);
async function addTodo(text: string) {
const tempId = crypto.randomUUID();
const optimisticTodo = { id: tempId, text, completed: false, pending: true };
// Add optimistically
setTodos((prev) => [...prev, optimisticTodo]);
try {
const newTodo = await createTodo(text);
// Replace temp with real
setTodos((prev) =>
prev.map((t) => (t.id === tempId ? { ...newTodo, pending: false } : t))
);
} catch {
// Remove on error
setTodos((prev) => prev.filter((t) => t.id !== tempId));
showError('Failed to add todo');
}
}
return (
<ul>
{todos.map((todo) => (
<li key={todo.id} className={todo.pending ? 'opacity-50' : ''}>
{todo.text}
</li>
))}
</ul>
);
}
The pending flag provides visual feedback that the item is being saved.
TanStack Query optimistic updates
TanStack Query has built-in support through mutation callbacks:
import { useMutation, useQueryClient } from '@tanstack/react-query';
export function useLikeMutation(postId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (liked: boolean) => likePost(postId, liked),
onMutate: async (newLiked) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: ['post', postId] });
// Snapshot previous value
const previousPost = queryClient.getQueryData(['post', postId]);
// Optimistically update
queryClient.setQueryData(['post', postId], (old: Post) => ({
...old,
liked: newLiked,
likeCount: old.likeCount + (newLiked ? 1 : -1),
}));
// Return context for rollback
return { previousPost };
},
onError: (err, newLiked, context) => {
// Rollback on error
queryClient.setQueryData(['post', postId], context?.previousPost);
},
onSettled: () => {
// Refetch to ensure server state
queryClient.invalidateQueries({ queryKey: ['post', postId] });
},
});
}
The pattern: snapshot before, update optimistically, rollback on error, refetch on settle.
List operations
Lists require careful handling of temporary IDs:
// Adding to a list
onMutate: async (newItem) => {
await queryClient.cancelQueries({ queryKey: ['items'] });
const previousItems = queryClient.getQueryData(['items']);
const optimisticItem = {
...newItem,
id: `temp-${Date.now()}`,
pending: true,
};
queryClient.setQueryData(['items'], (old: Item[]) => [
...old,
optimisticItem,
]);
return { previousItems, tempId: optimisticItem.id };
},
onSuccess: (createdItem, variables, context) => {
// Replace temp with real ID
queryClient.setQueryData(['items'], (old: Item[]) =>
old.map((item) =>
item.id === context.tempId ? { ...createdItem, pending: false } : item
)
);
},
Reordering with optimistic updates
Drag-and-drop benefits from optimistic updates:
async function handleReorder(items: Item[], dragIndex: number, dropIndex: number) {
// Calculate new order
const reordered = arrayMove(items, dragIndex, dropIndex);
// Update UI immediately
setItems(reordered);
try {
await saveOrder(reordered.map((item) => item.id));
} catch {
// Revert to original order
setItems(items);
showError('Failed to save order');
}
}
The UI responds instantly to drags while the server persists changes.
Error handling strategies
| Strategy | When to use |
|---|---|
| Silent revert | Low-impact actions, user will retry naturally |
| Toast notification | User should know something failed |
| Inline error | Form fields, specific item errors |
| Modal dialog | Critical errors needing acknowledgment |
onError: (error, variables, context) => {
// Revert
queryClient.setQueryData(['items'], context?.previousItems);
// Notify user
toast.error('Failed to update. Your changes were reverted.');
},
Race conditions
Multiple rapid updates can cause race conditions:
// Problem: rapid toggles
// Click 1: liked = true (sends request)
// Click 2: liked = false (sends request)
// Response 1: server has liked = true
// Response 2: server has liked = false
// UI might show wrong state
// Solution: use latest state in request
const latestLiked = useRef(liked);
latestLiked.current = liked;
onSettled: () => {
// Only refetch if state matches what we sent
if (latestLiked.current === variables.liked) {
queryClient.invalidateQueries({ queryKey: ['post', postId] });
}
},
Or debounce rapid actions.
Visual feedback during optimistic state
Show users that changes are pending:
<li
key={item.id}
className={cn(
'p-4 border rounded',
item.pending && 'opacity-50 pointer-events-none'
)}
>
{item.name}
{item.pending && <Spinner className="ml-2 w-4 h-4" />}
</li>
Disabled interaction prevents double submissions. Reduced opacity signals in-progress state.
Testing optimistic updates
Test both success and failure paths:
test('reverts on error', async () => {
// Mock failed request
server.use(
rest.post('/api/like', (req, res, ctx) => res(ctx.status(500)))
);
render(<LikeButton post={mockPost} />);
// Click like
await userEvent.click(screen.getByRole('button'));
// Should show liked optimistically
expect(screen.getByText('❤️')).toBeInTheDocument();
// Wait for error and revert
await waitFor(() => {
expect(screen.getByText('🤍')).toBeInTheDocument();
});
});
Summary
Optimistic UI makes apps feel instant by updating before server confirmation. Use it for high-confidence actions with easy rollback. Implement with useOptimistic in React 19, manual state in earlier versions, or TanStack Query mutations. Always handle errors with graceful rollback and user feedback. Test both success and failure paths to ensure UI consistency.
