useOptimistic is the React 19 hook that makes UI updates feel instant. You don't wait for the server to confirm — you show the result immediately and reconcile after.
Here's how to use it correctly, and why the naive implementation breaks.
The pattern
'use client';
import { useOptimistic, useTransition } from 'react';
interface Message {
id: string;
content: string;
status: 'pending' | 'sent' | 'failed';
}
export function MessageThread({ messages }: { messages: Message[] }) {
const [pending, startTransition] = useTransition();
const [optimisticMessages, addOptimisticMessage] = useOptimistic(
messages,
(state, newMessage: Message) => [...state, newMessage]
);
async function sendMessage(content: string) {
const optimisticId = crypto.randomUUID();
// Show the message immediately with 'pending' status
addOptimisticMessage({
id: optimisticId,
content,
status: 'pending',
});
startTransition(async () => {
try {
// Actual server action
await createMessage(content);
// On success: React discards the optimistic state,
// replaces it with the real server data from the next render
} catch (error) {
// On failure: the optimistic state is automatically rolled back
// Show the user an error somehow
}
});
}
return (
<div>
{optimisticMessages.map((message) => (
<div key={message.id} className={message.status === 'pending' ? 'opacity-60' : ''}>
{message.content}
{message.status === 'pending' && <span>Sending...</span>}
</div>
))}
<MessageInput onSend={sendMessage} disabled={pending} />
</div>
);
}
What's happening:
- User sends a message
-
addOptimisticMessageimmediately adds it to the displayed list withstatus: 'pending' - The server action runs asynchronously
- On success: React re-renders with real server data, replacing the optimistic entry
- On failure: The optimistic entry is removed (rolled back to pre-optimistic state)
The user sees instant feedback. The server stays authoritative.
The naive version that breaks
The most common mistake is doing optimistic updates in useState instead:
// WRONG — manual optimistic update
const [messages, setMessages] = useState(initialMessages);
async function sendMessage(content: string) {
// Add optimistically
const temp = { id: 'temp', content, status: 'pending' };
setMessages(prev => [...prev, temp]);
try {
const real = await createMessage(content);
// Remove temp, add real — race condition if multiple messages sent
setMessages(prev => prev.filter(m => m.id !== 'temp').concat(real));
} catch {
setMessages(prev => prev.filter(m => m.id !== 'temp'));
}
}
Problems with this approach:
- Race conditions — send message A and B quickly, temp IDs collide
- State divergence — if the server sends a real-time update (WebSocket) while the optimistic state is active, you have two sources of truth
- No automatic rollback — you have to manually handle every failure case
- Not concurrent mode safe — React 18+ can interrupt renders; manual state management breaks
useOptimistic handles all of these correctly.
Optimistic updates for lists (add, update, delete)
Adding items:
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state, newTodo: Todo) => [...state, newTodo]
);
Updating items:
const [optimisticTodos, updateOptimisticTodo] = useOptimistic(
todos,
(state, { id, changes }: { id: string; changes: Partial<Todo> }) =>
state.map(todo => todo.id === id ? { ...todo, ...changes } : todo)
);
// Usage: mark as complete immediately
updateOptimisticTodo({ id: todoId, changes: { completed: true } });
await toggleTodoComplete(todoId); // Server action
Deleting items:
const [optimisticTodos, removeOptimisticTodo] = useOptimistic(
todos,
(state, idToRemove: string) => state.filter(todo => todo.id !== idToRemove)
);
// Usage: remove immediately
removeOptimisticTodo(todoId);
await deleteTodo(todoId); // Server action
With Server Actions
useOptimistic is designed to work with Next.js Server Actions:
// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
const content = formData.get('content') as string;
await db.post.create({
data: { content, authorId: getCurrentUserId() },
});
revalidatePath('/feed'); // Triggers re-render with real data
}
// components/Feed.tsx
'use client'
import { useOptimistic } from 'react';
import { createPost } from '@/app/actions';
export function Feed({ posts }: { posts: Post[] }) {
const [optimisticPosts, addOptimisticPost] = useOptimistic(
posts,
(state, newPost: Post) => [newPost, ...state]
);
async function handleSubmit(formData: FormData) {
addOptimisticPost({
id: 'optimistic-' + Date.now(),
content: formData.get('content') as string,
author: { name: 'You' },
createdAt: new Date(),
});
await createPost(formData);
// revalidatePath in the server action triggers a re-render
// useOptimistic replaces the optimistic entry with the real data
}
return (
<>
<PostForm action={handleSubmit} />
{optimisticPosts.map(post => <PostCard key={post.id} post={post} />)}
</>
);
}
Showing status: pending vs confirmed
The pattern that works best for collaborative UIs:
const [optimisticItems, dispatchOptimistic] = useOptimistic(
serverItems,
(state, action: { type: 'add' | 'update' | 'delete'; payload: any }) => {
switch (action.type) {
case 'add':
return [...state, { ...action.payload, _optimistic: true }];
case 'update':
return state.map(item =>
item.id === action.payload.id
? { ...item, ...action.payload, _optimistic: true }
: item
);
case 'delete':
return state.filter(item => item.id !== action.payload.id);
}
}
);
// In the render
{optimisticItems.map(item => (
<Item
key={item.id}
item={item}
pending={item._optimistic === true} // Show subtle loading state
/>
))}
The _optimistic flag lets you show a subtle pending state (reduced opacity, spinner) on items that haven't been confirmed by the server yet.
When NOT to use useOptimistic
- Payment flows — never optimistically show a payment as succeeded. The server must confirm.
- Irreversible operations — deleting important data should wait for confirmation. Optimistically "deleting" and then the server fails means a confusing rollback.
- Real-time collaborative editing — optimistic updates and operational transforms don't mix well without careful design.
The rule of thumb: if being wrong for 200ms would confuse or harm the user, wait for server confirmation.
useOptimistic is one of the cleanest APIs in React 19. It solves a real problem (optimistic UI state) with minimal boilerplate and correct concurrent mode behavior. The useTransition + useOptimistic combination handles 90% of the "instant feedback" patterns you need in a modern web app.
The AI SaaS Starter Kit includes patterns for optimistic updates throughout the dashboard — form submissions, list operations, and status changes. It's wired for React 19 from the start.
Top comments (0)