Users hate waiting. Every millisecond of delay creates friction and makes your app feel sluggish. Optimistic updates show results immediately while the server catches up. When it confirms success, you're done. When it fails, you roll back gracefully.
The Psychology of Perceived Performance
Research shows users perceive applications as faster when they see immediate feedback, can continue working without breaks, and errors are rare. This last point is crucial: optimistic updates work because most operations succeed.
Implementing Optimistic Updates in React
React 19's useOptimistic makes this a first-class pattern:
'use client'
import { useOptimistic, useTransition } from 'react'
export function TodoList({ initialTodos }) {
const [isPending, startTransition] = useTransition()
const [optimisticTodos, updateOptimisticTodos] = useOptimistic(
initialTodos,
(currentTodos, action) => {
switch (action.type) {
case 'toggle':
return currentTodos.map(todo =>
todo.id === action.id ? { ...todo, completed: !todo.completed } : todo
)
case 'delete':
return currentTodos.filter(todo => todo.id !== action.id)
}
}
)
const handleToggle = async (id) => {
startTransition(async () => {
updateOptimisticTodos({ type: 'toggle', id })
await toggleTodo(id)
})
}
return (
<ul>
{optimisticTodos.map(todo => (
<li key={todo.id} style={{ opacity: isPending ? 0.7 : 1 }}>
<input type="checkbox" checked={todo.completed} onChange={() => handleToggle(todo.id)} />
{todo.text}
</li>
))}
</ul>
)
}
Handling Failures: The Rollback Dance
useOptimistic handles automatic rollback, but you need to inform users:
const handleSubmit = async (text) => {
startTransition(async () => {
addOptimisticComment({ text, author: 'You', pending: true })
try {
await addComment(postId, text)
} catch (err) {
toast.error('Failed to post comment', {
action: { label: 'Retry', onClick: () => handleSubmit(text) }
})
}
})
}
Race Conditions and Request Ordering
Multiple rapid requests can cause state confusion. Use request IDs or debouncing:
function LikeButton({ postId, initialLiked }) {
const [liked, setLiked] = useState(initialLiked)
const latestRequestRef = useRef(null)
const handleClick = async () => {
const requestId = crypto.randomUUID()
latestRequestRef.current = requestId
setLiked(!liked) // Optimistic
const result = await toggleLike(postId)
if (latestRequestRef.current === requestId) {
setLiked(result.liked) // Server truth
}
}
}
When NOT to Use Optimistic Updates
Some actions should wait for server confirmation:
- Financial transactions: Never show payment as successful before it succeeds
- Destructive actions: Deletions should confirm first
- Actions with dependencies: If B depends on A completing
- High-failure-rate operations: More than 5% failure causes frustrating flickering
Key Takeaways
Show immediate feedback, always. Even just an opacity change.
Handle failures gracefully. Error messages and retry options.
Guard against race conditions. Request IDs or debouncing.
Know when to wait. Some actions must confirm before showing success.
