Optimistic Updates in React: Making Your UI Feel Instant
Waiting for a server response before updating the UI feels sluggish.
Optimistic updates show the result immediately, then reconcile with the server.
The Pattern
User clicks 'Like'
1. Immediately update UI (show filled heart)
2. Send API request in background
3a. Success: server confirms — do nothing (already correct)
3b. Failure: rollback UI to original state
With React Query
function LikeButton({ postId, liked, likeCount }: LikeButtonProps) {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: () => toggleLike(postId),
onMutate: async () => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: ['post', postId] })
// Snapshot current value
const previous = queryClient.getQueryData(['post', postId])
// Optimistically update
queryClient.setQueryData(['post', postId], (old: Post) => ({
...old,
liked: !old.liked,
likeCount: old.liked ? old.likeCount - 1 : old.likeCount + 1,
}))
return { previous }
},
onError: (err, _variables, context) => {
// Rollback on failure
if (context?.previous) {
queryClient.setQueryData(['post', postId], context.previous)
}
},
onSettled: () => {
// Always refetch to ensure sync
queryClient.invalidateQueries({ queryKey: ['post', postId] })
},
})
return (
<button onClick={() => mutation.mutate()} className={liked ? 'text-red-500' : 'text-gray-400'}>
<HeartIcon /> {likeCount}
</button>
)
}
With useOptimistic (React 19)
'use client'
import { useOptimistic, useTransition } from 'react'
function TodoList({ todos }: { todos: Todo[] }) {
const [isPending, startTransition] = useTransition()
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state, newTodo: Todo) => [...state, newTodo]
)
const addTodo = async (text: string) => {
const tempTodo = { id: crypto.randomUUID(), text, done: false }
startTransition(async () => {
addOptimisticTodo(tempTodo) // shows immediately
await createTodo(text) // server action
// After server action completes, React reconciles real data
})
}
return (
<ul>
{optimisticTodos.map(todo => (
<li key={todo.id} className={isPending ? 'opacity-70' : ''}>
{todo.text}
</li>
))}
</ul>
)
}
With Zustand
const useCartStore = create((set) => ({
items: [],
addItem: async (product: Product) => {
// Optimistic update
set(state => ({ items: [...state.items, { ...product, pending: true }] }))
try {
const saved = await api.addToCart(product.id)
// Replace pending item with server response
set(state => ({
items: state.items.map(item =>
item.id === product.id ? { ...saved, pending: false } : item
),
}))
} catch {
// Rollback
set(state => ({
items: state.items.filter(item => item.id !== product.id),
}))
toast.error('Failed to add to cart')
}
},
}))
When NOT to Use Optimistic Updates
- Payment actions (show pending state, wait for confirmation)
- Destructive actions that are hard to reverse
- When server validation is complex and likely to reject
- When showing wrong data briefly could cause real harm
For these: show a loading spinner and wait.
Loading States That Work
// Skeleton screens > spinners for layout-level loading
// Inline indicators for action feedback
function SaveButton({ onSave }: { onSave: () => Promise<void> }) {
const [state, setState] = useState<'idle' | 'saving' | 'saved'>('idle')
const handleClick = async () => {
setState('saving')
await onSave()
setState('saved')
setTimeout(() => setState('idle'), 2000)
}
return (
<button onClick={handleClick} disabled={state === 'saving'}>
{state === 'idle' && 'Save'}
{state === 'saving' && 'Saving...'}
{state === 'saved' && 'Saved!'}
</button>
)
}
The AI SaaS Starter Kit ships with React Query configured for optimistic updates and the full SaaS UI patterns pre-built. $99 one-time.
Top comments (0)