Waiting for server responses feels slow. Optimistic updates provide instant feedback by updating the UI immediately, assuming the operation will succeed. React's useOptimistic hook makes this easy—update UI optimistically, and it automatically rolls back on errors. The result? Interfaces that feel as fast as native apps while maintaining data integrity!
What Are Optimistic Updates?
⏳ Traditional (Wait for Server)
- User clicks "Like"
- Button shows loading spinner
- Wait for server response (500ms - 2s)
- Update UI to show liked state
User Experience: Feels slow and unresponsive. User waits for every action.
⚡ Optimistic (Instant Feedback)
- User clicks "Like"
- UI updates instantly to show liked state
- Server processes in background
- Roll back if server returns error
User Experience: Instant, responsive, feels like a native app.
When to Use Optimistic Updates
- ✅ Good for: Like buttons, toggles, simple updates, adding items to lists
- ✅ High success rate: Operations that rarely fail
- ✅ Quick operations: Actions that complete fast
- ❌ Avoid for: Critical operations (payments, deletions), operations with validation, multi-step processes
useOptimistic Hook
Basic Concept
'use client';
import { useOptimistic } from 'react';
function Component({ initialData }) {
const [optimisticData, addOptimistic] = useOptimistic(
initialData,
(currentState, optimisticValue) => {
// Return new optimistic state
return optimisticValue;
}
);
async function handleAction() {
// 1. Update UI optimistically
addOptimistic(newValue);
// 2. Call Server Action
await serverAction();
// 3. Automatically rolls back on error
// 4. Real data replaces optimistic on success
}
return <div>{/* Use optimisticData */}</div>;
}
// ✅ optimisticData: Current state (optimistic or real)
// ✅ addOptimistic: Function to add optimistic updates
// ✅ Automatic rollback on error
// ✅ Real data replaces optimistic on successSimple Like Button Example
'use server';
import { revalidateTag } from 'next/cache';
export async function likePost(postId: string) {
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, 500));
// Update database
await db.posts.update({
where: { id: postId },
data: {
likes: { increment: 1 },
},
});
revalidateTag('posts');
return { success: true };
}
// ✅ Server Action
// ✅ Database update
// ✅ Revalidation'use client';
import { useOptimistic } from 'react';
import { likePost } from '@/app/actions/posts';
interface LikeButtonProps {
postId: string;
initialLikes: number;
}
export function LikeButton({ postId, initialLikes }: LikeButtonProps) {
const [optimisticLikes, addOptimisticLike] = useOptimistic(
initialLikes,
(currentLikes, amount: number) => currentLikes + amount
);
async function handleLike() {
// Add optimistic like immediately
addOptimisticLike(1);
// Call Server Action in background
await likePost(postId);
}
return (
<button
onClick={handleLike}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
❤️ {optimisticLikes} Likes
</button>
);
}
// ✅ Instant UI update
// ✅ Server Action in background
// ✅ Automatic rollback if fails
// ✅ Real count replaces optimistic on successComplete Todo List with Optimistic Updates
Server Actions
'use server';
import { revalidatePath } from 'next/cache';
export async function addTodo(text: string) {
const todo = await db.todos.create({
data: {
text,
completed: false,
},
});
revalidatePath('/todos');
return todo;
}
export async function toggleTodo(id: string) {
const todo = await db.todos.findUnique({
where: { id },
});
await db.todos.update({
where: { id },
data: {
completed: !todo.completed,
},
});
revalidatePath('/todos');
}
export async function deleteTodo(id: string) {
await db.todos.delete({
where: { id },
});
revalidatePath('/todos');
}
// ✅ Three Server Actions
// ✅ Database operations
// ✅ RevalidationTodo List Component with useOptimistic
'use client';
import { useOptimistic, useRef } from 'react';
import { addTodo, toggleTodo, deleteTodo } from '@/app/actions/todos';
interface Todo {
id: string;
text: string;
completed: boolean;
}
export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
const formRef = useRef<HTMLFormElement>(null);
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
initialTodos,
(state, newTodo: Todo | { id: string; action: 'toggle' | 'delete' }) => {
// Handle different actions
if ('action' in newTodo) {
if (newTodo.action === 'delete') {
// Remove todo
return state.filter(todo => todo.id !== newTodo.id);
}
if (newTodo.action === 'toggle') {
// Toggle completed
return state.map(todo =>
todo.id === newTodo.id
? { ...todo, completed: !todo.completed }
: todo
);
}
}
// Add new todo
return [...state, newTodo as Todo];
}
);
async function handleAddTodo(formData: FormData) {
const text = formData.get('text') as string;
// Add optimistic todo
const tempId = `temp-${Date.now()}`;
addOptimisticTodo({
id: tempId,
text,
completed: false,
});
// Clear form
formRef.current?.reset();
// Call Server Action
await addTodo(text);
}
async function handleToggle(id: string) {
// Optimistically toggle
addOptimisticTodo({ id, action: 'toggle' });
// Call Server Action
await toggleTodo(id);
}
async function handleDelete(id: string) {
// Optimistically delete
addOptimisticTodo({ id, action: 'delete' });
// Call Server Action
await deleteTodo(id);
}
return (
<div className="max-w-2xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-8">Todos (Optimistic)</h1>
{/* Add form */}
<form ref={formRef} action={handleAddTodo} className="mb-8">
<div className="flex gap-2">
<input
type="text"
name="text"
placeholder="What needs to be done?"
required
className="flex-1 px-4 py-2 border rounded-lg"
/>
<button
type="submit"
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Add
</button>
</div>
</form>
{/* Todo list */}
<div className="space-y-2">
{optimisticTodos.map(todo => (
<div
key={todo.id}
className={`flex items-center gap-4 p-4 bg-white rounded-lg shadow ${
todo.id.startsWith('temp-') ? 'opacity-50' : ''
}`}
>
{/* Toggle checkbox */}
<button
onClick={() => handleToggle(todo.id)}
className={`w-6 h-6 border-2 rounded flex items-center justify-center ${
todo.completed
? 'bg-blue-600 border-blue-600'
: 'border-gray-300'
}`}
>
{todo.completed && <span className="text-white">✓</span>}
</button>
{/* Todo text */}
<span
className={`flex-1 ${
todo.completed ? 'line-through text-gray-500' : ''
}`}
>
{todo.text}
</span>
{/* Delete button */}
<button
onClick={() => handleDelete(todo.id)}
className="px-3 py-1 text-red-600 hover:bg-red-50 rounded"
>
Delete
</button>
</div>
))}
</div>
</div>
);
}
// ✅ Add, toggle, delete all optimistic
// ✅ Instant feedback
// ✅ Temp items shown with opacity
// ✅ Automatic rollback on errorError Handling with Optimistic Updates
Detecting and Handling Errors
'use client';
import { useOptimistic, useState } from 'react';
import { addTodo } from '@/app/actions/todos';
export function TodoListWithErrors({ initialTodos }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
initialTodos,
(state, newTodo) => [...state, newTodo]
);
const [error, setError] = useState<string | null>(null);
async function handleAddTodo(formData: FormData) {
const text = formData.get('text') as string;
// Clear previous errors
setError(null);
// Add optimistic todo
const tempId = `temp-${Date.now()}`;
addOptimisticTodo({
id: tempId,
text,
completed: false,
});
try {
// Call Server Action
const result = await addTodo(text);
if (!result.success) {
setError(result.error || 'Failed to add todo');
}
} catch (err) {
setError('Network error. Please try again.');
}
}
return (
<div>
<form action={handleAddTodo}>
<input name="text" required />
<button type="submit">Add</button>
</form>
{/* Error display */}
{error && (
<div className="p-4 bg-red-100 text-red-800 rounded-lg mb-4">
<p className="font-semibold">Error</p>
<p>{error}</p>
</div>
)}
{/* Todo list */}
<div>
{optimisticTodos.map(todo => (
<div key={todo.id}>{todo.text}</div>
))}
</div>
</div>
);
}
// ✅ Error state management
// ✅ Try-catch for Server Action
// ✅ Error display to user
// ✅ Optimistic update auto-rolls backRetry Logic
'use client';
import { useOptimistic, useState } from 'react';
export function TodoWithRetry({ initialTodos }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
initialTodos,
(state, newTodo) => [...state, newTodo]
);
const [failedTodo, setFailedTodo] = useState<string | null>(null);
async function handleAddTodo(text: string, isRetry = false) {
if (!isRetry) {
setFailedTodo(null);
}
// Add optimistic
const tempId = `temp-${Date.now()}`;
addOptimisticTodo({ id: tempId, text, completed: false });
try {
await addTodo(text);
} catch (err) {
// Store failed todo for retry
setFailedTodo(text);
}
}
async function handleRetry() {
if (failedTodo) {
await handleAddTodo(failedTodo, true);
}
}
return (
<div>
{/* Form */}
<form action={(formData) => handleAddTodo(formData.get('text') as string)}>
<input name="text" required />
<button type="submit">Add</button>
</form>
{/* Retry button */}
{failedTodo && (
<div className="p-4 bg-yellow-100 rounded-lg mb-4">
<p>Failed to add: "{failedTodo}"</p>
<button
onClick={handleRetry}
className="mt-2 px-4 py-2 bg-blue-600 text-white rounded"
>
Retry
</button>
</div>
)}
{/* Todo list */}
{optimisticTodos.map(todo => (
<div key={todo.id}>{todo.text}</div>
))}
</div>
);
}
// ✅ Retry failed operations
// ✅ User can fix errors
// ✅ Better error recoveryAdvanced Optimistic Update Patterns
Pattern 1: Social Media Like with Count
'use client';
import { useOptimistic, useState } from 'react';
import { likePost, unlikePost } from '@/app/actions/posts';
interface Post {
id: string;
title: string;
likes: number;
likedByUser: boolean;
}
export function PostCard({ post }: { post: Post }) {
const [optimisticLike, setOptimisticLike] = useOptimistic(
{ likes: post.likes, likedByUser: post.likedByUser },
(state, newState: { likes: number; likedByUser: boolean }) => newState
);
async function handleLike() {
const newLikedState = !optimisticLike.likedByUser;
const newLikeCount = newLikedState
? optimisticLike.likes + 1
: optimisticLike.likes - 1;
// Optimistic update
setOptimisticLike({
likes: newLikeCount,
likedByUser: newLikedState,
});
// Server Action
if (newLikedState) {
await likePost(post.id);
} else {
await unlikePost(post.id);
}
}
return (
<div className="p-6 bg-white rounded-lg shadow">
<h3 className="text-xl font-bold mb-4">{post.title}</h3>
<button
onClick={handleLike}
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition ${
optimisticLike.likedByUser
? 'bg-red-600 text-white'
: 'bg-gray-200 text-gray-800 hover:bg-gray-300'
}`}
>
<span>{optimisticLike.likedByUser ? '❤️' : '🤍'}</span>
<span>{optimisticLike.likes} Likes</span>
</button>
</div>
);
}
// ✅ Like/unlike toggle
// ✅ Live count updates
// ✅ Visual state change
// ✅ Instant feedbackPattern 2: Comment with Optimistic Add
'use client';
import { useOptimistic, useRef } from 'react';
import { addComment } from '@/app/actions/comments';
interface Comment {
id: string;
text: string;
author: string;
createdAt: Date;
}
export function CommentSection({
postId,
initialComments,
currentUser,
}: {
postId: string;
initialComments: Comment[];
currentUser: string;
}) {
const formRef = useRef<HTMLFormElement>(null);
const [optimisticComments, addOptimisticComment] = useOptimistic(
initialComments,
(state, newComment: Comment) => [...state, newComment]
);
async function handleSubmit(formData: FormData) {
const text = formData.get('text') as string;
// Optimistic comment
addOptimisticComment({
id: `temp-${Date.now()}`,
text,
author: currentUser,
createdAt: new Date(),
});
// Clear form
formRef.current?.reset();
// Server Action
await addComment(postId, text);
}
return (
<div>
<h3 className="text-xl font-bold mb-4">
Comments ({optimisticComments.length})
</h3>
{/* Comment form */}
<form ref={formRef} action={handleSubmit} className="mb-6">
<textarea
name="text"
placeholder="Add a comment..."
required
className="w-full px-4 py-2 border rounded-lg mb-2"
rows={3}
/>
<button
type="submit"
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Comment
</button>
</form>
{/* Comments list */}
<div className="space-y-4">
{optimisticComments.map(comment => (
<div
key={comment.id}
className={`p-4 bg-gray-50 rounded-lg ${
comment.id.startsWith('temp-') ? 'opacity-60' : ''
}`}
>
<div className="flex items-center gap-2 mb-2">
<span className="font-semibold">{comment.author}</span>
<span className="text-sm text-gray-500">
{new Date(comment.createdAt).toLocaleDateString()}
</span>
{comment.id.startsWith('temp-') && (
<span className="text-xs text-gray-500">(sending...)</span>
)}
</div>
<p className="text-gray-700">{comment.text}</p>
</div>
))}
</div>
</div>
);
}
// ✅ Comments appear instantly
// ✅ Temp indicator while sending
// ✅ Count updates immediately
// ✅ Form clears on submitPattern 3: Drag and Drop Reordering
'use client';
import { useOptimistic } from 'react';
import { reorderItems } from '@/app/actions/items';
interface Item {
id: string;
text: string;
order: number;
}
export function ReorderableList({ initialItems }: { initialItems: Item[] }) {
const [optimisticItems, setOptimisticItems] = useOptimistic(
initialItems,
(state, newItems: Item[]) => newItems
);
async function handleReorder(fromIndex: number, toIndex: number) {
// Calculate new order
const reordered = [...optimisticItems];
const [moved] = reordered.splice(fromIndex, 1);
reordered.splice(toIndex, 0, moved);
// Update order numbers
const updated = reordered.map((item, index) => ({
...item,
order: index,
}));
// Optimistic update
setOptimisticItems(updated);
// Server Action
await reorderItems(updated.map(item => ({ id: item.id, order: item.order })));
}
function handleDragStart(e: React.DragEvent, index: number) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', index.toString());
}
function handleDragOver(e: React.DragEvent) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}
function handleDrop(e: React.DragEvent, toIndex: number) {
e.preventDefault();
const fromIndex = parseInt(e.dataTransfer.getData('text/plain'));
if (fromIndex !== toIndex) {
handleReorder(fromIndex, toIndex);
}
}
return (
<div className="space-y-2">
{optimisticItems.map((item, index) => (
<div
key={item.id}
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, index)}
className="p-4 bg-white rounded-lg shadow cursor-move hover:shadow-lg transition"
>
<span className="text-gray-500 mr-4">#{item.order + 1}</span>
{item.text}
</div>
))}
</div>
);
}
// ✅ Instant reordering
// ✅ Drag and drop UX
// ✅ Order persists to server
// ✅ No lag during dragOptimistic Updates Structure
Organization of components with useOptimistic
Select a file or folder to see details
Optimistic Updates Best Practices
1. Use for High-Success Operations
// ✅ GOOD: High success rate operations
// - Like/unlike (toggle)
// - Mark as read/unread
// - Add to list
// - Simple updates
// ❌ BAD: Operations that might fail
// - Payment processing
// - Complex validation
// - File uploads
// - Critical deletions
// Optimistic updates for likely-to-succeed operations only2. Show Visual Indicators for Pending States
// ✅ GOOD: Visual feedback
<div
className={`p-4 rounded ${
item.id.startsWith('temp-')
? 'opacity-50 border-2 border-dashed'
: 'border border-solid'
}`}
>
{item.text}
{item.id.startsWith('temp-') && (
<span className="text-sm text-gray-500 ml-2">(saving...)</span>
)}
</div>
// Users know operation is in progress3. Handle Errors Gracefully
// ✅ GOOD: Error handling
const [error, setError] = useState<string | null>(null);
async function handleAction() {
setError(null);
addOptimistic(newValue);
try {
await serverAction();
} catch (err) {
setError('Failed to save. Please try again.');
}
}
// Show error to user
{error && (
<div className="bg-red-100 text-red-800 p-4 rounded">
{error}
</div>
)}
// Automatic rollback + error message = good UX4. Provide Undo/Retry Options
// ✅ GOOD: Undo option
const [lastAction, setLastAction] = useState(null);
async function handleDelete(item) {
setLastAction({ type: 'delete', item });
addOptimistic({ id: item.id, action: 'delete' });
await deleteItem(item.id);
}
function handleUndo() {
if (lastAction) {
// Restore item
addOptimistic(lastAction.item);
setLastAction(null);
}
}
// Undo button shown briefly after delete
{lastAction && (
<button onClick={handleUndo}>Undo</button>
)}
// Users can recover from mistakes5. Keep Optimistic Logic Simple
// ✅ GOOD: Simple optimistic logic
const [optimisticCount, addOptimistic] = useOptimistic(
initialCount,
(state, increment: number) => state + increment
);
// ❌ BAD: Complex logic in optimistic updates
const [optimisticData, addOptimistic] = useOptimistic(
initialData,
(state, action) => {
// Complex filtering, sorting, validation
// Multiple conditional branches
// API calls (!)
// Don't do this!
}
);
// Keep optimistic updates simple and predictableKey Takeaways
- useOptimistic - instant UI updates before server confirms
- Automatic rollback - reverts on error automatically
- Best for - toggles, likes, simple updates with high success rate
- Visual indicators - show pending state to users
- Error handling - catch errors and show messages
- Keep simple - complex logic belongs in Server Actions
- Native-app feel - instant feedback improves UX dramatically
- Progressive enhancement - still works without JavaScript
🎉 Forms and Data Mutations Section Complete!
You've completed the Forms and Data Mutations section! You've mastered:
- ✅ Understanding Server Actions
- ✅ Form handling with Server Actions
- ✅ Form validation with Zod
- ✅ useFormStatus and useFormState hooks
- ✅ Revalidating data after mutations
- ✅ Optimistic updates
You now have complete mastery of forms in Next.js! You can build progressively enhanced forms that work without JavaScript, validate data with type-safe schemas, manage loading states, revalidate cached data, and provide instant feedback with optimistic updates. These skills enable you to create forms that feel as responsive as native apps while maintaining data integrity and security.
⚡ The Complete Stack
Combine everything: Server Actions for mutations, Zod for validation, useFormStatus for loading states, revalidatePath for cache updates, and useOptimistic for instant feedback. This stack creates forms that are secure, type-safe, accessible, and feel incredibly fast!