Skip to main content

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.

MutationOptimistic?Reason
Like/unlike a postYesNon-critical; easy to roll back
Add a commentYesNon-critical; temporary id on client
Follow a userYesNon-critical; easy to revert
Update profileMaybeCritical data; confirm first, then optimize
Charge a credit cardNoCritical; must wait for server confirmation
Delete accountNoCritical; 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 useOptimistic and useActionState for 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).

Further Reading