PatternsData Fetching Strategies
Optimistic Updates
Update UI immediately before server confirms, for instant feedback
Don't make users wait for the server. Update the UI immediately and handle failures gracefully. This guide shows how.
🎯 The Goal
Without optimistic updates:
User clicks "Like" → Wait for server → Update UI
⏱️ 500ms delay = Bad UX
With optimistic updates:
User clicks "Like" → Update UI instantly → Sync with server
⚡ 0ms perceived delay = Great UX❤️ Pattern 1: Simple Like Button
Basic optimistic update example.
function LikeButton({ postId, initialLiked, initialCount }: Props) {
const [liked, setLiked] = useState(initialLiked);
const [count, setCount] = useState(initialCount);
async function handleClick() {
// Save previous state for rollback
const prevLiked = liked;
const prevCount = count;
try {
// 1. Update UI immediately (optimistic)
setLiked(!liked);
setCount(liked ? count - 1 : count + 1);
// 2. Send request to server
await fetch(`/api/posts/${postId}/like`, {
method: liked ? 'DELETE' : 'POST'
});
// 3. Success! UI already updated
} catch (error) {
// 4. Rollback on error
setLiked(prevLiked);
setCount(prevCount);
toast.error('Failed to like post');
}
}
return (
<button onClick={handleClick}>
{liked ? '❤️' : '🤍'} {count}
</button>
);
}🔄 Pattern 2: React Query Optimistic Updates
Use React Query's built-in optimistic updates.
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface Post {
id: string;
liked: boolean;
likeCount: number;
}
function LikeButton({ postId }: { postId: string }) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: async (liked: boolean) => {
const method = liked ? 'POST' : 'DELETE';
return fetch(`/api/posts/${postId}/like`, { method });
},
// Optimistic update
onMutate: async (newLiked) => {
// Cancel outgoing queries
await queryClient.cancelQueries({ queryKey: ['post', postId] });
// Snapshot previous value
const previousPost = queryClient.getQueryData<Post>(['post', postId]);
// Optimistically update
queryClient.setQueryData<Post>(['post', postId], (old) => ({
...old!,
liked: newLiked,
likeCount: old!.likeCount + (newLiked ? 1 : -1)
}));
// Return context for rollback
return { previousPost };
},
// Rollback on error
onError: (err, newLiked, context) => {
queryClient.setQueryData(['post', postId], context?.previousPost);
toast.error('Failed to update like');
},
// Refetch on success/error
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['post', postId] });
},
});
const { data: post } = useQuery({
queryKey: ['post', postId],
queryFn: () => fetchPost(postId)
});
return (
<button onClick={() => mutation.mutate(!post?.liked)}>
{post?.liked ? '❤️' : '🤍'} {post?.likeCount}
</button>
);
}📝 Pattern 3: Todo List with Optimistic Add/Delete
Complete CRUD with optimistic updates.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
interface Todo {
id: string;
text: string;
completed: boolean;
}
function TodoList() {
const queryClient = useQueryClient();
const { data: todos = [] } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos
});
// Add todo
const addMutation = useMutation({
mutationFn: (text: string) =>
fetch('/api/todos', {
method: 'POST',
body: JSON.stringify({ text })
}).then(r => r.json()),
onMutate: async (text) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);
// Optimistic add with temporary ID
const optimisticTodo: Todo = {
id: `temp-${Date.now()}`,
text,
completed: false
};
queryClient.setQueryData<Todo[]>(['todos'], (old = []) => [
...old,
optimisticTodo
]);
return { previousTodos };
},
onError: (err, text, context) => {
queryClient.setQueryData(['todos'], context?.previousTodos);
toast.error('Failed to add todo');
},
onSuccess: (newTodo, text, context) => {
// Replace temp ID with real ID from server
queryClient.setQueryData<Todo[]>(['todos'], (old = []) =>
old.map(todo =>
todo.id.startsWith('temp-') ? newTodo : todo
)
);
}
});
// Delete todo
const deleteMutation = useMutation({
mutationFn: (id: string) =>
fetch(`/api/todos/${id}`, { method: 'DELETE' }),
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);
// Optimistic delete
queryClient.setQueryData<Todo[]>(['todos'], (old = []) =>
old.filter(todo => todo.id !== id)
);
return { previousTodos };
},
onError: (err, id, context) => {
queryClient.setQueryData(['todos'], context?.previousTodos);
toast.error('Failed to delete todo');
}
});
// Toggle completion
const toggleMutation = useMutation({
mutationFn: ({ id, completed }: { id: string; completed: boolean }) =>
fetch(`/api/todos/${id}`, {
method: 'PATCH',
body: JSON.stringify({ completed })
}),
onMutate: async ({ id, completed }) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);
// Optimistic toggle
queryClient.setQueryData<Todo[]>(['todos'], (old = []) =>
old.map(todo =>
todo.id === id ? { ...todo, completed } : todo
)
);
return { previousTodos };
},
onError: (err, variables, context) => {
queryClient.setQueryData(['todos'], context?.previousTodos);
toast.error('Failed to update todo');
}
});
return (
<div>
<form onSubmit={(e) => {
e.preventDefault();
const text = new FormData(e.currentTarget).get('text') as string;
addMutation.mutate(text);
e.currentTarget.reset();
}}>
<input name="text" placeholder="Add todo..." />
<button type="submit">Add</button>
</form>
<ul>
{todos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={(e) =>
toggleMutation.mutate({
id: todo.id,
completed: e.target.checked
})
}
/>
{todo.text}
<button onClick={() => deleteMutation.mutate(todo.id)}>
Delete
</button>
</li>
))}
</ul>
</div>
);
}🛒 Pattern 4: Shopping Cart
Optimistic cart operations.
interface CartItem {
id: string;
productId: string;
quantity: number;
price: number;
}
function ShoppingCart() {
const queryClient = useQueryClient();
const { data: cart = [] } = useQuery({
queryKey: ['cart'],
queryFn: fetchCart
});
const updateQuantity = useMutation({
mutationFn: ({ itemId, quantity }: { itemId: string; quantity: number }) =>
fetch(`/api/cart/${itemId}`, {
method: 'PATCH',
body: JSON.stringify({ quantity })
}),
onMutate: async ({ itemId, quantity }) => {
await queryClient.cancelQueries({ queryKey: ['cart'] });
const previousCart = queryClient.getQueryData<CartItem[]>(['cart']);
// Optimistic update
queryClient.setQueryData<CartItem[]>(['cart'], (old = []) =>
old.map(item =>
item.id === itemId ? { ...item, quantity } : item
)
);
return { previousCart };
},
onError: (err, variables, context) => {
queryClient.setQueryData(['cart'], context?.previousCart);
toast.error('Failed to update quantity');
}
});
const removeItem = useMutation({
mutationFn: (itemId: string) =>
fetch(`/api/cart/${itemId}`, { method: 'DELETE' }),
onMutate: async (itemId) => {
await queryClient.cancelQueries({ queryKey: ['cart'] });
const previousCart = queryClient.getQueryData<CartItem[]>(['cart']);
// Optimistic remove with animation
queryClient.setQueryData<CartItem[]>(['cart'], (old = []) =>
old.filter(item => item.id !== itemId)
);
return { previousCart, removedItem: previousCart?.find(i => i.id === itemId) };
},
onError: (err, itemId, context) => {
queryClient.setQueryData(['cart'], context?.previousCart);
toast.error('Failed to remove item');
},
onSuccess: (data, itemId, context) => {
// Show undo option
toast.success('Item removed', {
action: {
label: 'Undo',
onClick: () => {
// Add back to cart
if (context.removedItem) {
queryClient.setQueryData<CartItem[]>(['cart'], (old = []) => [
...old,
context.removedItem!
]);
}
}
}
});
}
});
const total = cart.reduce((sum, item) => sum + item.price * item.quantity, 0);
return (
<div>
<h2>Cart ({cart.length} items)</h2>
{cart.map(item => (
<div key={item.id}>
<span>{item.productId}</span>
<input
type="number"
value={item.quantity}
onChange={(e) => updateQuantity.mutate({
itemId: item.id,
quantity: parseInt(e.target.value)
})}
min="1"
/>
<button onClick={() => removeItem.mutate(item.id)}>
Remove
</button>
</div>
))}
<div>Total: ${total.toFixed(2)}</div>
</div>
);
}💬 Pattern 5: Real-Time Comments
Optimistic comment posting.
interface Comment {
id: string;
text: string;
author: string;
createdAt: Date;
status?: 'pending' | 'sent' | 'failed';
}
function CommentSection({ postId }: { postId: string }) {
const queryClient = useQueryClient();
const { user } = useAuth();
const { data: comments = [] } = useQuery({
queryKey: ['comments', postId],
queryFn: () => fetchComments(postId)
});
const postComment = useMutation({
mutationFn: (text: string) =>
fetch(`/api/posts/${postId}/comments`, {
method: 'POST',
body: JSON.stringify({ text })
}).then(r => r.json()),
onMutate: async (text) => {
await queryClient.cancelQueries({ queryKey: ['comments', postId] });
const previousComments = queryClient.getQueryData<Comment[]>(['comments', postId]);
// Optimistic comment with temporary ID and "pending" status
const optimisticComment: Comment = {
id: `temp-${Date.now()}`,
text,
author: user.name,
createdAt: new Date(),
status: 'pending'
};
queryClient.setQueryData<Comment[]>(['comments', postId], (old = []) => [
...old,
optimisticComment
]);
return { previousComments };
},
onError: (err, text, context) => {
// Mark as failed instead of removing
queryClient.setQueryData<Comment[]>(['comments', postId], (old = []) =>
old.map(comment =>
comment.id.startsWith('temp-')
? { ...comment, status: 'failed' as const }
: comment
)
);
toast.error('Failed to post comment');
},
onSuccess: (newComment, text, context) => {
// Replace temp with real comment
queryClient.setQueryData<Comment[]>(['comments', postId], (old = []) =>
old.map(comment =>
comment.id.startsWith('temp-')
? { ...newComment, status: 'sent' as const }
: comment
)
);
}
});
return (
<div>
<h3>Comments</h3>
{comments.map(comment => (
<div key={comment.id} className={comment.status}>
<strong>{comment.author}</strong>
<p>{comment.text}</p>
{comment.status === 'pending' && <span>Sending...</span>}
{comment.status === 'failed' && (
<button onClick={() => postComment.mutate(comment.text)}>
Retry
</button>
)}
</div>
))}
<form onSubmit={(e) => {
e.preventDefault();
const text = new FormData(e.currentTarget).get('text') as string;
postComment.mutate(text);
e.currentTarget.reset();
}}>
<textarea name="text" placeholder="Write a comment..." />
<button type="submit">Post</button>
</form>
</div>
);
}📊 Best Practices
1. Always Provide Rollback
// ✅ GOOD: Save previous state
const previous = queryClient.getQueryData(['data']);
// ... optimistic update ...
// On error: restore previous
// ❌ BAD: No rollback
// ... optimistic update ...
// On error: ??? data is wrong now2. Show Status Indicators
// ✅ GOOD: Show pending state
<div className={item.status === 'pending' ? 'opacity-50' : ''}>
{item.text}
{item.status === 'pending' && <Spinner />}
</div>
// ❌ BAD: No indication
<div>{item.text}</div>3. Handle Failures Gracefully
// ✅ GOOD: Allow retry
{comment.status === 'failed' && (
<button onClick={retry}>Retry</button>
)}
// ❌ BAD: Silent failure
// User has no idea it failed4. Use Temporary IDs
// ✅ GOOD: Unique temp ID
const tempId = `temp-${Date.now()}-${Math.random()}`;
// ❌ BAD: Duplicate IDs
const tempId = 'temp';🏢 Real-World Examples
// Optimistic tweets
// Instant likes/retweets
// Retry on failure
// Show sending indicatorDiscord
// Optimistic messages
// Failed state with retry
// Offline queue
// Message orderingNotion
// Optimistic everything
// Offline-first
// Conflict resolution
// Auto-save with sync status📚 Key Takeaways
- Update UI immediately - Don't wait for server
- Always have rollback - Handle failures
- Show status - Pending, sent, failed
- Use temp IDs - Replace with real on success
- Cancel queries - Prevent race conditions
- Provide retry - Let users fix failures
- Test offline - Optimistic updates shine here
Optimistic updates are the difference between good and great UX. Always use them for user actions.