Introduction
Optimistic updates are a cornerstone of modern, responsive user interfaces. Instead of showing a loading spinner while waiting for a server to confirm an action, you immediately update the UI to reflect the expected outcome. If the server request succeeds, the UI is already correct. If it fails, you roll back to the previous state. This pattern makes applications feel instantaneousāeven on slow networks.
React 19 introduces useOptimistic, a built-in hook that simplifies optimistic updates. It manages the temporary optimistic state, handles rollbacks on errors, and integrates seamlessly with React's transition system. In this comprehensive guide, we'll explore useOptimistic from every angleābasic usage, complex list manipulations, form integrations, error handling, and production patterns. You'll learn how to build UIs that feel fast without sacrificing data integrity.
Understanding useOptimistic: Core Concepts
What is useOptimistic?
useOptimistic is a React hook that manages a piece of state that can be temporarily updated to an "optimistic" value while an async operation is in progress. When the operation completes, the state either reverts to the real value (if the optimistic value was wrong) or keeps the optimistic value (if it matches reality).
import { useOptimistic } from 'react'
function Counter({ count }: { count: number }) {
const [optimisticCount, addOptimisticCount] = useOptimistic(
count,
(current, increment: number) => current + increment
)
async function handleIncrement() {
addOptimisticCount(1) // Immediately shows count + 1
await updateCountOnServer(count + 1) // Server confirms
// optimisticCount automatically reverts to new count
}
return (
<div>
<p>Count: {optimisticCount}</p>
<button onClick={handleIncrement}>Increment</button>
</div>
)
}The Mental Model
Think of useOptimistic as a "preview" layer on top of your real state:
- Real state: The actual data from the server (props or fetched data)
- Optimistic state: A temporary overlay that shows what the user expects
- Sync: When real state updates (server response arrives), optimistic state syncs automatically
Real State: [A, B, C]
Optimistic: [A, B, C, D] ā User added D
ā Server confirms
Real State: [A, B, C, D]
Optimistic: [A, B, C, D] ā Synced automatically
useOptimistic vs useState
The key difference from useState is that useOptimistic automatically reverts to the real value when the underlying data changes:
// With useState - manual revert required
const [items, setItems] = useState(serverItems)
async function addItem(item) {
setItems([...items, item]) // Optimistic
try {
await saveItem(item)
} catch {
setItems(items) // Manual revert!
}
}
// With useOptimistic - automatic revert
const [optimisticItems, addOptimisticItem] = useOptimistic(
serverItems,
(current, newItem) => [...current, newItem]
)
async function addItem(item) {
addOptimisticItem(item) // Optimistic
await saveItem(item)
// Automatic revert on error or state change!
}Architecture and Design Patterns
Basic Update Pattern
The reducer function receives the current optimistic state and an update payload:
const [optimisticMessages, addOptimisticMessage] = useOptimistic(
messages,
(state, newMessage: Message) => [...state, { ...newMessage, sending: true }]
)
async function sendMessage(text: string) {
addOptimisticMessage({ id: 'temp', text, sender: 'me' })
await sendMessageToServer(text)
}Replace Pattern
Replace an existing item with an updated version:
const [optimisticTodos, updateOptimisticTodo] = useOptimistic(
todos,
(state, updatedTodo: Todo) =>
state.map(todo =>
todo.id === updatedTodo.id ? { ...updatedTodo, pending: true } : todo
)
)
async function toggleTodo(todo: Todo) {
updateOptimisticTodo({ ...todo, completed: !todo.completed })
await updateTodoOnServer(todo.id, { completed: !todo.completed })
}Remove Pattern
const [optimisticItems, removeOptimisticItem] = useOptimistic(
items,
(state, itemId: string) => state.filter(item => item.id !== itemId)
)
async function deleteItem(itemId: string) {
removeOptimisticItem(itemId)
await deleteItemOnServer(itemId)
}Step-by-Step Implementation
Todo List with Full CRUD
import { useOptimistic, useRef, useTransition } from 'react'
interface Todo {
id: string
text: string
completed: boolean
pending?: boolean
}
function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
const [optimisticTodos, updateTodos] = useOptimistic(
initialTodos,
(state, action: { type: string; payload: any }) => {
switch (action.type) {
case 'add':
return [...state, { ...action.payload, pending: true }]
case 'toggle':
return state.map(todo =>
todo.id === action.payload.id
? { ...todo, completed: !todo.completed, pending: true }
: todo
)
case 'delete':
return state.filter(todo => todo.id !== action.payload.id)
case 'edit':
return state.map(todo =>
todo.id === action.payload.id
? { ...todo, text: action.payload.text, pending: true }
: todo
)
default:
return state
}
}
)
const [isPending, startTransition] = useTransition()
async function addTodo(formData: FormData) {
const text = formData.get('text') as string
const newTodo = { id: crypto.randomUUID(), text, completed: false }
startTransition(async () => {
updateTodos({ type: 'add', payload: newTodo })
await createTodoOnServer(newTodo)
})
}
async function toggleTodo(todo: Todo) {
startTransition(async () => {
updateTodos({ type: 'toggle', payload: { id: todo.id } })
await updateTodoOnServer(todo.id, { completed: !todo.completed })
})
}
async function deleteTodo(id: string) {
startTransition(async () => {
updateTodos({ type: 'delete', payload: { id } })
await deleteTodoOnServer(id)
})
}
return (
<div>
<form action={addTodo}>
<input name="text" placeholder="Add todo..." required />
<button type="submit" disabled={isPending}>
{isPending ? 'Adding...' : 'Add'}
</button>
</form>
<ul>
{optimisticTodos.map(todo => (
<li
key={todo.id}
style={{ opacity: todo.pending ? 0.7 : 1 }}
>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo)}
/>
<span style={{
textDecoration: todo.completed ? 'line-through' : 'none'
}}>
{todo.text}
</span>
<button onClick={() => deleteTodo(todo.id)}>Delete</button>
</li>
))}
</ul>
</div>
)
}Like Button with Optimistic Counter
function LikeButton({ postId, initialLikes, isLiked }: LikeButtonProps) {
const [optimisticLikes, setOptimisticLikes] = useOptimistic(
{ count: initialLikes, liked: isLiked },
(state, newLiked: boolean) => ({
count: newLiked ? state.count + 1 : state.count - 1,
liked: newLiked,
})
)
const [isPending, startTransition] = useTransition()
async function toggleLike() {
startTransition(async () => {
const newLiked = !optimisticLikes.liked
setOptimisticLikes(newLiked)
try {
await toggleLikeOnServer(postId, newLiked)
} catch {
// Revert happens automatically when props update
}
})
}
return (
<button
onClick={toggleLike}
disabled={isPending}
className={optimisticLikes.liked ? 'liked' : ''}
>
{optimisticLikes.liked ? 'ā¤ļø' : 'š¤'} {optimisticLikes.count}
</button>
)
}Shopping Cart with Optimistic Updates
function ShoppingCart({ items }: { items: CartItem[] }) {
const [optimisticItems, updateCart] = useOptimistic(
items,
(state, action: CartAction) => {
switch (action.type) {
case 'add': {
const existing = state.find(i => i.productId === action.productId)
if (existing) {
return state.map(i =>
i.productId === action.productId
? { ...i, quantity: i.quantity + 1, updating: true }
: i
)
}
return [...state, {
productId: action.productId,
quantity: 1,
name: action.name,
price: action.price,
updating: true,
}]
}
case 'remove':
return state.filter(i => i.productId !== action.productId)
case 'updateQuantity':
return state.map(i =>
i.productId === action.productId
? { ...i, quantity: action.quantity, updating: true }
: i
)
default:
return state
}
}
)
const total = optimisticItems.reduce(
(sum, item) => sum + item.price * item.quantity,
0
)
async function addToCart(product: Product) {
updateCart({
type: 'add',
productId: product.id,
name: product.name,
price: product.price,
})
await addToCartOnServer(product.id)
}
async function removeFromCart(productId: string) {
updateCart({ type: 'remove', productId })
await removeFromCartOnServer(productId)
}
async function updateQuantity(productId: string, quantity: number) {
updateCart({ type: 'updateQuantity', productId, quantity })
await updateCartQuantityOnServer(productId, quantity)
}
return (
<div>
{optimisticItems.map(item => (
<div
key={item.productId}
style={{ opacity: item.updating ? 0.7 : 1 }}
>
<span>{item.name}</span>
<QuantityControl
value={item.quantity}
onChange={qty => updateQuantity(item.productId, qty)}
/>
<span>${(item.price * item.quantity).toFixed(2)}</span>
<button onClick={() => removeFromCart(item.productId)}>Remove</button>
</div>
))}
<div className="total">Total: ${total.toFixed(2)}</div>
</div>
)
}Real-World Use Cases
Use Case 1: Real-Time Chat
function ChatMessages({ messages }: { messages: Message[] }) {
const [optimisticMessages, addMessage] = useOptimistic(
messages,
(state, newMessage: Message) => [...state, { ...newMessage, sending: true }]
)
const [isPending, startTransition] = useTransition()
async function sendMessage(text: string) {
const message: Message = {
id: crypto.randomUUID(),
text,
sender: 'me',
timestamp: Date.now(),
}
startTransition(async () => {
addMessage(message)
try {
await sendMessageToServer(text)
} catch (error) {
// Message will automatically revert when messages prop updates
}
})
}
return (
<div className="chat">
<div className="messages">
{optimisticMessages.map(msg => (
<div
key={msg.id}
className={`message ${msg.sender} ${msg.sending ? 'pending' : ''}`}
>
<p>{msg.text}</p>
{msg.sending && <span className="sending-indicator">Sending...</span>}
</div>
))}
</div>
<MessageInput onSend={sendMessage} disabled={isPending} />
</div>
)
}Use Case 2: Inline Editing
function InlineEditor({ item }: { item: EditableItem }) {
const [optimisticItem, updateItem] = useOptimistic(
item,
(current, updates: Partial<EditableItem>) => ({
...current,
...updates,
saving: true,
})
)
const [isPending, startTransition] = useTransition()
async function handleSave(field: string, value: string) {
startTransition(async () => {
updateItem({ [field]: value })
await updateItemOnServer(item.id, { [field]: value })
})
}
return (
<div className="inline-editor" style={{ opacity: optimisticItem.saving ? 0.7 : 1 }}>
<EditableText
value={optimisticItem.title}
onSave={(value) => handleSave('title', value)}
disabled={isPending}
/>
<EditableText
value={optimisticItem.description}
onSave={(value) => handleSave('description', value)}
disabled={isPending}
/>
{optimisticItem.saving && <span>Saving...</span>}
</div>
)
}Use Case 3: Drag and Drop Reordering
function SortableList({ items }: { items: SortableItem[] }) {
const [optimisticItems, reorder] = useOptimistic(
items,
(state, { fromIndex, toIndex }: { fromIndex: number; toIndex: number }) => {
const newItems = [...state]
const [moved] = newItems.splice(fromIndex, 1)
newItems.splice(toIndex, 0, moved)
return newItems.map((item, i) => ({ ...item, order: i, reordering: true }))
}
)
async function handleDragEnd(fromIndex: number, toIndex: number) {
reorder({ fromIndex, toIndex })
await reorderOnServer(
optimisticItems[fromIndex].id,
optimisticItems[toIndex].id
)
}
return (
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="list">
{(provided) => (
<div ref={provided.innerRef} {...provided.droppableProps}>
{optimisticItems.map((item, index) => (
<Draggable key={item.id} draggableId={item.id} index={index}>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
style={{
...provided.draggableProps.style,
opacity: item.reordering ? 0.7 : 1,
}}
>
{item.title}
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
)
}Best Practices for Production
-
Always handle errors: Optimistic updates assume success. Always handle the failure caseāeither by reverting (automatic with
useOptimistic) or showing an error notification. -
Use with transitions: Wrap optimistic updates in
startTransitionto keep the UI responsive during the async operation. This prevents blocking the main thread. -
Show visual feedback: Use opacity, animations, or disabled states to indicate that an update is optimistic. Users should know the action is processing.
-
Don't optimistic-update critical data: For financial transactions or irreversible actions, show a confirmation or loading state instead of optimistic updates.
-
Combine with error boundaries: If the server rejects an action, the error should be caught and displayed appropriately. Don't let optimistic errors crash the app.
-
Handle concurrent updates: If the user makes multiple rapid optimistic updates, ensure the server responses are processed correctly. Use request IDs or timestamps.
-
Test failure scenarios: Write tests that verify the rollback behavior when server requests fail. Ensure the UI returns to the correct state.
-
Use meaningful IDs: Temporary IDs (like
crypto.randomUUID()) should be replaced with server-generated IDs. Handle the ID mapping in your update logic.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| No error handling | UI stuck in optimistic state | Catch errors and revert, or rely on prop updates |
| Showing loading spinners | Defeats purpose of optimistic updates | Use subtle indicators (opacity, badges) |
| Optimistic updates for critical actions | Data inconsistency | Use explicit confirmation for irreversible actions |
| Not handling race conditions | Stale data displayed | Use request ordering or last-write-wins |
| Temporary IDs not replaced | Broken references | Map temp IDs to real IDs after server response |
| Missing visual feedback | Users unsure if action registered | Add subtle pending indicators |
Performance Optimization
// Debounce rapid optimistic updates
function useDebouncedOptimistic<T, U>(
realState: T,
reducer: (state: T, action: U) => T,
delay: number = 300
) {
const [optimisticState, setOptimistic] = useOptimistic(realState, reducer)
const timeoutRef = useRef<NodeJS.Timeout>()
function dispatch(action: U) {
setOptimistic(action)
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
timeoutRef.current = setTimeout(() => {
// Server update happens after debounce
}, delay)
}
return [optimisticState, dispatch] as const
}
// Batch multiple optimistic updates
function useBatchedOptimistic<T>(
realState: T[],
reducer: (state: T[], action: any) => T[]
) {
const [optimisticState, dispatch] = useOptimistic(realState, reducer)
const batchRef = useRef<any[]>([])
const rafRef = useRef<number>()
function batchedDispatch(action: any) {
batchRef.current.push(action)
if (!rafRef.current) {
rafRef.current = requestAnimationFrame(() => {
const actions = batchRef.current
batchRef.current = []
rafRef.current = undefined
// Apply all actions at once
actions.forEach(a => dispatch(a))
})
}
}
return [optimisticState, batchedDispatch] as const
}Comparison with Alternatives
| Feature | useOptimistic | useState + try/catch | React Query mutation | SWR mutate |
|---|---|---|---|---|
| Automatic revert | Yes | Manual | Yes | Yes |
| Built into React | Yes | Yes | No | No |
| Rollback on error | Automatic | Manual | Automatic | Automatic |
| Cache integration | Manual | Manual | Automatic | Automatic |
| TypeScript | Excellent | Good | Excellent | Good |
| Learning curve | Low | Low | Medium | Low |
| Bundle size | 0 KB | 0 KB | 13 KB | 4.3 KB |
When to use each:
- useOptimistic: Simple optimistic updates in React 19+ with fine-grained control
- React Query mutations: Production apps with complex cache management
- SWR mutate: Lightweight apps that need optimistic updates with minimal setup
- useState + try/custom logic: When you need maximum control over the update flow
Advanced Patterns and Techniques
Optimistic Form with Action State
import { useOptimistic, useActionState } from 'react'
function CommentForm({ postId }: { postId: string }) {
const [optimisticComments, addOptimisticComment] = useOptimistic(
[] as Comment[],
(state, newComment: Comment) => [...state, newComment]
)
const [state, submitAction, isPending] = useActionState(
async (prev: any, formData: FormData) => {
const text = formData.get('text') as string
const comment: Comment = {
id: crypto.randomUUID(),
text,
author: 'You',
createdAt: new Date().toISOString(),
}
addOptimisticComment(comment)
try {
await createComment(postId, text)
return { success: true }
} catch (error) {
return { error: 'Failed to post comment' }
}
},
null
)
return (
<div>
<div className="comments">
{optimisticComments.map(comment => (
<div key={comment.id} className="comment">
<strong>{comment.author}</strong>
<p>{comment.text}</p>
</div>
))}
</div>
<form action={submitAction}>
<textarea name="text" placeholder="Write a comment..." />
<button type="submit" disabled={isPending}>
{isPending ? 'Posting...' : 'Post Comment'}
</button>
</form>
{state?.error && <p className="error">{state.error}</p>}
</div>
)
}Multi-Step Optimistic Updates
function OrderFlow({ order }: { order: Order }) {
const [optimisticOrder, updateOrder] = useOptimistic(
order,
(state, step: { action: string; data: any }) => {
switch (step.action) {
case 'confirm':
return { ...state, status: 'confirmed', processing: true }
case 'ship':
return { ...state, status: 'shipped', trackingNumber: step.data.trackingNumber, processing: true }
case 'deliver':
return { ...state, status: 'delivered', processing: true }
default:
return state
}
}
)
async function advanceOrder(action: string, data?: any) {
updateOrder({ action, data })
await updateOrderOnServer(order.id, action, data)
}
return (
<div>
<h2>Order #{optimisticOrder.id}</h2>
<p>Status: {optimisticOrder.status}</p>
{optimisticOrder.processing && <span>Processing...</span>}
{optimisticOrder.status === 'pending' && (
<button onClick={() => advanceOrder('confirm')}>Confirm Order</button>
)}
{optimisticOrder.status === 'confirmed' && (
<button onClick={() => advanceOrder('ship', { trackingNumber: 'TRK123' })}>
Ship Order
</button>
)}
</div>
)
}Testing Strategies
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
test('optimistic todo appears immediately', async () => {
const user = userEvent.setup()
render(<TodoList initialTodos={[]} />)
const input = screen.getByPlaceholderText('Add todo...')
await user.type(input, 'New todo')
await user.click(screen.getByText('Add'))
// Optimistic todo should appear immediately
expect(screen.getByText('New todo')).toBeInTheDocument()
expect(screen.getByText('New todo').closest('li')).toHaveStyle({ opacity: 0.7 })
})
test('optimistic update reverts on error', async () => {
server.use(
http.post('/api/todos', () => {
return HttpResponse.json({ message: 'Error' }, { status: 500 })
})
)
const user = userEvent.setup()
render(<TodoList initialTodos={[]} />)
await user.type(screen.getByPlaceholderText('Add todo...'), 'Bad todo')
await user.click(screen.getByText('Add'))
// Optimistic todo appears
expect(screen.getByText('Bad todo')).toBeInTheDocument()
// After error, it should revert
await waitFor(() => {
expect(screen.queryByText('Bad todo')).not.toBeInTheDocument()
})
})
test('multiple rapid optimistic updates', async () => {
const user = userEvent.setup()
render(<TodoList initialTodos={[]} />)
// Add multiple todos rapidly
for (let i = 1; i <= 3; i++) {
await user.type(screen.getByPlaceholderText('Add todo...'), `Todo ${i}`)
await user.click(screen.getByText('Add'))
}
// All should appear optimistically
expect(screen.getByText('Todo 1')).toBeInTheDocument()
expect(screen.getByText('Todo 2')).toBeInTheDocument()
expect(screen.getByText('Todo 3')).toBeInTheDocument()
})Future Outlook
useOptimistic is part of React 19's suite of hooks designed for async UI patterns. Combined with useActionState, useFormStatus, and Server Actions, it creates a complete framework for form handling with optimistic updates. As the React ecosystem adopts these patterns, expect libraries like React Query to integrate more deeply with useOptimistic, providing cache-aware optimistic updates.
The trend toward optimistic UI is accelerating. Users expect instant feedback, and useOptimistic makes it straightforward to deliver that experience without the complexity of manual state management. Understanding this hook now prepares you for the next generation of React applications.
Conclusion
useOptimistic brings optimistic updates into React as a first-class primitive. By managing temporary state that automatically reverts to real data, it eliminates the boilerplate and error-prone logic of manual optimistic updates.
Key takeaways:
useOptimisticautomatically reverts to real state when props change or errors occur- Use with transitions for responsive, non-blocking optimistic updates
- Show subtle visual feedback (opacity, badges) for pending optimistic state
- Don't use for critical/irreversible actionsāuse explicit confirmation instead
- Combine with Error Boundaries for complete error handling
- Test both success and failure scenarios to verify rollback behavior
Start by adding optimistic updates to your most latency-sensitive interactionsālike buttons, toggles, and form submissions. The immediate feedback will dramatically improve perceived performance, and useOptimistic ensures data integrity even when things go wrong.