React 19: Optimistic UI Updates with useOptimistic
Master the useOptimistic hook to create instant, responsive user interfaces with automatic rollback on errors.
Topics Covered:
Prerequisites:
- Advanced State Management Patterns
Overview
useOptimistic is a powerful React 19 hook that enables optimistic UI updates - showing immediate feedback to users before server confirmation. This creates a more responsive, modern user experience. When combined with error handling, it automatically reverts changes if the action fails, ensuring data consistency. This tutorial covers when and how to use useOptimistic effectively.
Lesson 1: Understanding Optimistic UI Patterns
Optimistic UI updates show changes immediately, assuming the action will succeed. If it fails, the UI reverts to the previous state. Benefits: • Instant feedback - no waiting for server • Better perceived performance • Smoother user experience • Reduced perceived latency When to Use: • Actions likely to succeed (likes, follows, saves) • High success rate operations • When rollback is acceptable • Improving perceived performance When NOT to Use: • Critical financial transactions • Actions that frequently fail • When data integrity is paramount • Operations requiring server validation
// Traditional approach (pessimistic)
async function handleLike() {
setLoading(true);
try {
await likePost(postId);
setLikes(prev => prev + 1);
} catch (error) {
showError('Failed to like');
} finally {
setLoading(false);
}
}
// User sees: Loading... → Success
// Feels slow, waits for server
// Optimistic approach
async function handleLike() {
addOptimisticLike(1); // Update immediately
try {
await likePost(postId);
setLikes(prev => prev + 1); // Confirm
} catch (error) {
// Automatic rollback
showError('Failed to like');
}
}
// User sees: Immediate update
// Feels instant, confirms in backgroundOptimistic UI provides immediate feedback. The user sees changes instantly, and the UI confirms or reverts based on the server response.
Lesson 2: useOptimistic Hook Basics
useOptimistic manages optimistic state updates with automatic rollback. Hook Signature: ```typescript const [optimisticState, addOptimistic] = useOptimistic( state, updateFn ); ``` Parameters: • state: The actual state value • updateFn: Function that computes optimistic state Returns: • optimisticState: The optimistic state (or actual if no pending updates) • addOptimistic: Function to trigger optimistic update
import { useOptimistic, useState } from 'react';
function LikeButton() {
const [likes, setLikes] = useState(10);
const [optimisticLikes, addOptimisticLike] = useOptimistic(
likes,
// Update function: (currentState, optimisticValue) => newState
(currentLikes, amount: number) => currentLikes + amount
);
async function handleLike() {
// Step 1: Optimistic update (immediate)
addOptimisticLike(1);
try {
// Step 2: Server call
const response = await fetch('/api/like', {
method: 'POST',
body: JSON.stringify({ postId: 1 }),
});
if (!response.ok) throw new Error('Failed');
// Step 3: Update actual state (confirms optimistic)
const data = await response.json();
setLikes(data.likes);
} catch (error) {
// Step 4: On error, useOptimistic automatically reverts
// because we didn't update the actual state
console.error('Like failed:', error);
}
}
return (
<button onClick={handleLike}>
❤️ {optimisticLikes}
</button>
);
}
// How it works:
// 1. User clicks → optimisticLikes becomes 11 (optimistic)
// 2. Server succeeds → setLikes(11) → optimisticLikes stays 11 (confirmed)
// 3. Server fails → setLikes not called → optimisticLikes reverts to 10useOptimistic manages temporary optimistic state. When you call addOptimisticLike, it immediately shows the optimistic value. If the actual state updates, it confirms. If not (error case), it automatically reverts.
Lesson 3: Complex Optimistic Updates
useOptimistic can handle complex state structures, not just numbers. Common Patterns: • Adding items to lists • Updating nested objects • Toggling boolean states • Incrementing/decrementing counters Best Practices: • Keep update function pure • Handle complex state transformations • Use proper TypeScript types • Consider edge cases
// Complex state: Array of items
function TodoList() {
const [todos, setTodos] = useState<Todo[]>([
{ id: 1, text: 'Learn React', completed: false },
]);
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(currentTodos, newTodo: Todo) => [...currentTodos, newTodo]
);
async function handleAddTodo(text: string) {
const tempTodo: Todo = {
id: Date.now(), // Temporary ID
text,
completed: false,
};
// Add optimistically
addOptimisticTodo(tempTodo);
try {
const savedTodo = await createTodo(text);
// Replace temp with real todo
setTodos(prev => [
...prev.filter(t => t.id !== tempTodo.id),
savedTodo,
]);
} catch (error) {
// Auto-reverts, removing tempTodo
console.error('Failed to add todo');
}
}
// Complex: Updating nested state
function CommentList() {
const [comments, setComments] = useState<Comment[]>([]);
const [optimisticComments, updateOptimistic] = useOptimistic(
comments,
(current, update: { id: number; likes: number }) =>
current.map(comment =>
comment.id === update.id
? { ...comment, likes: update.likes }
: comment
)
);
async function handleLikeComment(commentId: number) {
const comment = comments.find(c => c.id === commentId);
if (!comment) return;
// Optimistic: increment likes
updateOptimistic({ id: commentId, likes: comment.likes + 1 });
try {
const updated = await likeComment(commentId);
setComments(prev =>
prev.map(c => (c.id === commentId ? updated : c))
);
} catch (error) {
// Auto-reverts
}
}
}
// Toggle pattern
function FollowButton({ userId }: { userId: number }) {
const [isFollowing, setIsFollowing] = useState(false);
const [optimisticFollowing, toggleOptimistic] = useOptimistic(
isFollowing,
(current, _) => !current // Toggle
);
async function handleToggle() {
toggleOptimistic(null); // Value doesn't matter for toggle
try {
if (isFollowing) {
await unfollow(userId);
} else {
await follow(userId);
}
setIsFollowing(prev => !prev);
} catch (error) {
// Auto-reverts
}
}
return (
<button onClick={handleToggle}>
{optimisticFollowing ? 'Unfollow' : 'Follow'}
</button>
);
}useOptimistic works with any state structure. The update function receives the current state and the optimistic value, returning the new optimistic state. Keep the function pure and predictable.
Lesson 4: Error Handling and Rollback
Proper error handling ensures data consistency when optimistic updates fail. Error Handling Strategies: • Automatic rollback (built-in) • Manual error notifications • Retry mechanisms • Error state management Common Patterns: • Show error messages • Retry failed operations • Log errors for debugging • Graceful degradation
// Error handling with notifications
function LikeButton() {
const [likes, setLikes] = useState(0);
const [error, setError] = useState<string | null>(null);
const [optimisticLikes, addOptimistic] = useOptimistic(
likes,
(current, amount: number) => current + amount
);
async function handleLike() {
// Clear previous errors
setError(null);
// Optimistic update
addOptimistic(1);
try {
const response = await fetch('/api/like', {
method: 'POST',
});
if (!response.ok) {
throw new Error('Like failed');
}
const data = await response.json();
setLikes(data.likes);
} catch (error) {
// Error occurred - optimistic state auto-reverts
setError('Failed to like. Please try again.');
// Optional: Retry mechanism
setTimeout(() => {
handleLike(); // Retry
}, 2000);
}
}
return (
<div>
<button onClick={handleLike}>
❤️ {optimisticLikes}
</button>
{error && <p className="error">{error}</p>}
</div>
);
}
// With retry logic
function useOptimisticWithRetry<T, A>(
state: T,
updateFn: (current: T, optimisticValue: A) => T
) {
const [optimisticState, addOptimistic] = useOptimistic(state, updateFn);
const [retryCount, setRetryCount] = useState(0);
return {
optimisticState,
addOptimistic: (value: A) => {
addOptimistic(value);
setRetryCount(0); // Reset retry count
},
retry: () => setRetryCount(prev => prev + 1),
retryCount,
};
}
// Advanced: Debounce rapid optimistic updates
function useDebouncedOptimistic<T, A>(
state: T,
updateFn: (current: T, optimisticValue: A) => T,
delay: number = 300
) {
const [optimisticState, addOptimistic] = useOptimistic(state, updateFn);
const timeoutRef = useRef<NodeJS.Timeout>();
const debouncedAdd = useCallback((value: A) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
addOptimistic(value);
}, delay);
}, [addOptimistic, delay]);
return { optimisticState, addOptimistic: debouncedAdd };
}Error handling with useOptimistic is straightforward - if the actual state doesn't update, the optimistic state automatically reverts. Add error messages and retry logic for better UX.
Lesson 5: Real-World Use Cases
Explore practical applications of useOptimistic in real applications. Common Use Cases: • Social media interactions (likes, comments) • Shopping cart updates • Form submissions • List item operations (add, delete, update) • Real-time collaboration features
// Real-world examples
// 1. Social media like counter
function Post({ postId }: { postId: number }) {
const [likes, setLikes] = useState(0);
const [isLiked, setIsLiked] = useState(false);
const [optimisticLikes, addOptimistic] = useOptimistic(
likes,
(current, delta: number) => current + delta
);
const [optimisticIsLiked, toggleOptimistic] = useOptimistic(
isLiked,
(current, _) => !current
);
async function handleLike() {
const wasLiked = isLiked;
toggleOptimistic(null);
addOptimistic(wasLiked ? -1 : 1);
try {
if (wasLiked) {
await unlikePost(postId);
setIsLiked(false);
setLikes(prev => prev - 1);
} else {
await likePost(postId);
setIsLiked(true);
setLikes(prev => prev + 1);
}
} catch (error) {
// Both revert automatically
}
}
return (
<button onClick={handleLike}>
{optimisticIsLiked ? '❤️' : '🤍'} {optimisticLikes}
</button>
);
}
// 2. Shopping cart
function ShoppingCart() {
const [items, setItems] = useState<CartItem[]>([]);
const [optimisticItems, addOptimistic] = useOptimistic(
items,
(current, newItem: CartItem) => [...current, newItem]
);
async function addToCart(product: Product) {
const tempItem: CartItem = {
id: `temp-${Date.now()}`,
product,
quantity: 1,
};
addOptimistic(tempItem);
try {
const savedItem = await addToCartAPI(product.id);
setItems(prev => [...prev.filter(i => i.id !== tempItem.id), savedItem]);
} catch (error) {
// Reverts
}
}
const total = optimisticItems.reduce(
(sum: number, item: CartItem) => sum + item.product.price * item.quantity,
0
);
return (
<div>
{optimisticItems.map(item => (
<CartItem key={item.id} item={item} />
))}
<div>Total: ${total.toFixed(2)}</div>
</div>
);
}
// 3. Comment submission
function CommentForm({ postId }: { postId: number }) {
const [comments, setComments] = useState<Comment[]>([]);
const [optimisticComments, addOptimistic] = useOptimistic(
comments,
(current, newComment: Comment) => [newComment, ...current]
);
async function handleSubmit(text: string) {
const tempComment: Comment = {
id: `temp-${Date.now()}`,
text,
author: currentUser,
createdAt: new Date(),
pending: true,
};
addOptimistic(tempComment);
try {
const saved = await postComment(postId, text);
setComments(prev => [
saved,
...prev.filter(c => c.id !== tempComment.id),
]);
} catch (error) {
// Reverts, removing temp comment
}
}
return (
<div>
{optimisticComments.map(comment => (
<Comment
key={comment.id}
comment={comment}
pending={comment.id.startsWith('temp-')}
/>
))}
<CommentForm onSubmit={handleSubmit} />
</div>
);
}useOptimistic shines in interactive applications where immediate feedback improves UX. Common patterns include likes, cart operations, comments, and real-time updates.
Conclusion
useOptimistic enables modern, responsive UIs by providing instant feedback. Use it for actions likely to succeed, always handle errors gracefully, and enjoy the improved user experience. The automatic rollback ensures data consistency even when operations fail.