Optimistic Updates in Server Actions
Optimistic updates mean assuming a Server Action will succeed and updating the UI immediately, before the server responds. When the user likes a post, show the heart as filled and increment the count right away, without waiting for the server. If the server confirms success, do nothing—the UI is already correct. If it fails, roll back the UI to the previous state and show an error. This pattern feels instant to users and significantly improves perceived performance.
Optimistic updates are an advanced UX pattern that requires careful state management. You need to track both the local (optimistic) state and the server's state, handle race conditions (multiple simultaneous requests), and gracefully roll back if the server rejects the mutation. With React 19's useOptimistic hook, this is now straightforward.
Basic Optimistic Updates with useOptimistic
The useOptimistic hook lets you assume a Server Action will succeed and display optimistic state while the request is in flight. Here's a simple example:
// app/components/LikeButton.tsx
'use client';
import { useOptimistic } from 'react';
import { toggleLike } from '@/app/actions';
export function LikeButton({ postId, liked, likeCount }) {
// optimisticLiked will be 'true' immediately when you click;
// it reverts to liked if the action fails
const [optimisticLiked, toggleOptimisticLike] = useOptimistic(liked);
const [optimisticCount, setOptimisticCount] = useOptimistic(likeCount);
async function handleLike() {
// Optimistically update the UI
toggleOptimisticLike(!optimisticLiked);
setOptimisticCount(optimisticLiked ? likeCount - 1 : likeCount + 1);
try {
// Call the Server Action
const result = await toggleLike(postId);
// Server confirmed; optimistic state stays
if (!result.success) {
// If server returns failure, revert (useOptimistic does this automatically)
throw new Error('Failed to toggle like');
}
} catch (error) {
// Revert optimistic updates on error
console.error(error);
// useOptimistic automatically reverts to the previous state
}
}
return (
<button onClick={handleLike}>
{optimisticLiked ? '❤️' : '🤍'} ({optimisticCount})
</button>
);
}
useOptimistic() takes an initial state and returns [optimisticState, updateOptimisticState]. When you call updateOptimisticState(), it immediately updates the component. If the Server Action throws an error, useOptimistic automatically reverts to the initial state.
Optimistic Updates for List Changes
More complex: adding or removing items from a list optimistically:
// app/actions.ts
'use server';
import { revalidateTag } from 'next/cache';
import { db } from '@/lib/db';
export async function deleteTask(id: string) {
const task = await db.tasks.findById(id);
if (!task) {
throw new Error('Task not found');
}
await db.tasks.delete(id);
revalidateTag('tasks');
return { success: true };
}
// app/components/TaskList.tsx
'use client';
import { useOptimistic } from 'react';
import { deleteTask } from '@/app/actions';
type Task = { id: string; title: string };
export function TaskList({ tasks }: { tasks: Task[] }) {
const [optimisticTasks, removeOptimisticTask] = useOptimistic(
tasks,
(state: Task[], id: string) => {
// Reducer function: remove the task with the given id
return state.filter((task) => task.id !== id);
}
);
async function handleDelete(id: string) {
// Optimistically remove the task from the list
removeOptimisticTask(id);
try {
await deleteTask(id);
} catch (error) {
// On error, useOptimistic reverts the list to the previous state
console.error('Delete failed:', error);
}
}
return (
<ul>
{optimisticTasks.map((task) => (
<li key={task.id}>
{task.title}
<button onClick={() => handleDelete(task.id)}>Delete</button>
</li>
))}
</ul>
);
}
useOptimistic() accepts a second argument—a reducer function that computes the optimistic state based on the current state and the action payload. Here, it removes the task with the given id. If the delete fails, the reducer is never applied, and the state reverts.
Optimistic Updates with Form Submission
When you use useActionState with optimistic updates, combine both hooks:
// app/components/CommentForm.tsx
'use client';
import { useActionState, useOptimistic } from 'react';
import { addComment } from '@/app/actions';
type Comment = { id: string; text: string; author: string };
export function CommentForm({ postId, comments }: { postId: string; comments: Comment[] }) {
const [optimisticComments, addOptimisticComment] = useOptimistic(
comments,
(state: Comment[], comment: Comment) => [...state, comment]
);
const [state, formAction, isPending] = useActionState(
async (prevState: any, formData: FormData) => {
const text = formData.get('text') as string;
if (!text.trim()) {
return { error: 'Comment cannot be empty', success: false };
}
// Optimistically add the comment
addOptimisticComment({
id: `temp-${Date.now()}`, // Temporary id
text,
author: 'You',
});
try {
const result = await addComment(postId, text);
if (!result.success) {
throw new Error(result.error || 'Failed to add comment');
}
return { success: true, error: null };
} catch (error) {
return { error: String(error), success: false };
}
},
{ success: false, error: null }
);
return (
<div>
<ul>
{optimisticComments.map((comment) => (
<li key={comment.id}>
<strong>{comment.author}:</strong> {comment.text}
</li>
))}
</ul>
<form action={formAction}>
<textarea
name="text"
placeholder="Add a comment"
disabled={isPending}
required
></textarea>
<button type="submit" disabled={isPending}>
{isPending ? 'Posting...' : 'Post'}
</button>
{state?.error && <p style={{ color: 'red' }}>{state.error}</p>}
</form>
</div>
);
}
The form shows the optimistic comment immediately, but with a temporary id. If the server confirms, great—the UI was already correct. If it fails, useOptimistic reverts the list, and the error message shows.
Handling Race Conditions
If a user performs multiple optimistic updates quickly, you need to handle overlapping requests. useOptimistic manages this by tracking pending actions. However, be careful with the temporary ids:
// For safer temporary ids, include metadata:
const tempId = `temp-${Date.now()}-${Math.random()}`;
// Then, when the server responds with the real id,
// you can replace the temp id in your component state.
Alternatively, disable the button during submission (isPending) to prevent multiple simultaneous requests.
When NOT to Use Optimistic Updates
Optimistic updates are best for non-critical mutations: likes, follows, comments. For critical mutations (payment processing, account deletion), wait for server confirmation before updating the UI. The cost of rolling back a like is minimal; rolling back a payment is catastrophic.
| Mutation | Optimistic? | Reason |
|---|---|---|
| Like/unlike a post | Yes | Non-critical; easy to roll back |
| Add a comment | Yes | Non-critical; temporary id on client |
| Follow a user | Yes | Non-critical; easy to revert |
| Update profile | Maybe | Critical data; confirm first, then optimize |
| Charge a credit card | No | Critical; must wait for server confirmation |
| Delete account | No | Critical; must have explicit server confirmation |
Key Takeaways
- Optimistic updates improve perceived performance by showing changes instantly before server confirmation.
- Use
useOptimistic()to assume a Server Action will succeed and display optimistic state. On error, it automatically reverts. useOptimistic()accepts a reducer function to compute optimistic state for complex mutations (list additions, deletions).- Combine
useOptimisticanduseActionStatefor forms that need both optimistic updates and error handling. - Use optimistic updates for non-critical mutations; wait for server confirmation for critical mutations (payments, account changes).
Frequently Asked Questions
What happens if two optimistic updates conflict?
useOptimistic queues updates and applies them sequentially. Each update is optimistically applied; if one fails, only that one reverts, and subsequent updates remain (if they succeeded on the server). This is generally safe because the server is the source of truth—it reconciles all updates.
Can I show a spinner while optimistic updates are pending?
Yes, use useActionState which provides an isPending boolean. Alternatively, track a pending state manually using useState and check if there are pending optimistic updates.
How do I assign permanent ids to optimistically added items?
When the server responds, it returns the new item's permanent id. You can update local state to replace the temporary id with the real one. However, with proper cache revalidation, the page will re-fetch data from the server, replacing all temporary ids automatically.
Should I use optimistic updates with ISR (Incremental Static Regeneration)?
Yes. Optimistic updates improve UX for users with JavaScript. If JavaScript fails, the form still works without optimistic updates, and the user waits for the page reload. ISR handles cache invalidation; optimistic updates handle perceived performance.
Can optimistic updates cause data loss?
No, because the server is the source of truth. If an optimistic update fails and you don't roll back manually, the server's version wins. Always implement rollback (which useOptimistic does automatically).