React useOptimistic — Instant UI Feedback with Optimistic Updates
Advertisement
Introduction
Why This Matters
Users expect instant feedback. When they click "Like" or add an item to a cart, a 300ms server round-trip feels slow. useOptimistic solves this by updating the UI immediately, then reconciling with the actual server response — or rolling back on failure. This pattern is sometimes called "optimistic concurrency."
Basic Syntax
const [optimisticState, addOptimistic] = useOptimistic(
actualState, // The real state from the server / parent
(currentState, optimisticValue) => newState // Optional reducer
)When addOptimistic(value) is called, React immediately applies the optional reducer to produce a new display state. Once the pending async operation settles, React reverts to actualState (now updated by the server response).
Todo List Example
'use client'
import { useOptimistic, useState } from 'react'
type Todo = { id: number; text: string; pending?: boolean }
export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
const [todos, setTodos] = useState<Todo[]>(initialTodos)
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state, newTodo: Todo) => [...state, newTodo]
)
async function handleAddTodo(formData: FormData) {
const text = formData.get('todo') as string
const optimistic: Todo = { id: Date.now(), text, pending: true }
addOptimisticTodo(optimistic)
try {
const res = await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify({ text })
})
const saved: Todo = await res.json()
setTodos(prev => [...prev, saved])
} catch {
// optimistic update reverts automatically when the action settles
// but we must reset todos to not include the failed item
setTodos(prev => prev) // no change — optimistic layer reverts
}
}
return (
<form action={handleAddTodo} className="space-y-2">
{optimisticTodos.map(todo => (
<div key={todo.id} className={todo.pending ? 'opacity-50' : ''}>
{todo.text}
{todo.pending && <span className="ml-2 text-xs text-gray-400">saving...</span>}
</div>
))}
<input name="todo" className="border px-2 py-1 rounded" required />
<button type="submit" className="bg-blue-600 text-white px-4 py-2 rounded">
Add Todo
</button>
</form>
)
}Social Feed: Like Button
One of the most common optimistic update patterns is a like/upvote button.
'use client'
import { useOptimistic } from 'react'
import { toggleLike } from '@/app/actions'
type LikeState = { liked: boolean; count: number }
export function LikeButton({ postId, initialLiked, initialCount }: {
postId: string
initialLiked: boolean
initialCount: number
}) {
const [optimisticLike, updateOptimisticLike] = useOptimistic(
{ liked: initialLiked, count: initialCount },
(state: LikeState): LikeState => ({
liked: !state.liked,
count: state.liked ? state.count - 1 : state.count + 1
})
)
async function handleLike() {
updateOptimisticLike(undefined) // reducer handles the toggle
await toggleLike(postId)
}
return (
<button
onClick={handleLike}
className={`flex items-center gap-2 px-3 py-1 rounded-full transition-colors ${
optimisticLike.liked
? 'bg-red-100 text-red-600'
: 'bg-gray-100 text-gray-600'
}`}
>
{optimisticLike.liked ? 'Liked' : 'Like'} {optimisticLike.count}
</button>
)
}Shopping Cart Quantity
'use client'
import { useOptimistic } from 'react'
import { updateCartQuantity } from '@/app/actions'
export function CartItem({ item }: { item: { id: string; name: string; price: number; quantity: number } }) {
const [optimisticQty, setOptimisticQty] = useOptimistic(item.quantity)
async function handleChange(newQty: number) {
if (newQty < 1) return
setOptimisticQty(newQty)
await updateCartQuantity(item.id, newQty)
}
return (
<div className="flex items-center gap-4 p-4 border rounded-lg">
<span className="flex-1 font-medium">{item.name}</span>
<div className="flex items-center gap-2">
<button
onClick={() => handleChange(optimisticQty - 1)}
className="w-8 h-8 rounded-full bg-gray-200 hover:bg-gray-300"
>
-
</button>
<span className="w-8 text-center font-semibold">{optimisticQty}</span>
<button
onClick={() => handleChange(optimisticQty + 1)}
className="w-8 h-8 rounded-full bg-gray-200 hover:bg-gray-300"
>
+
</button>
</div>
<span className="font-semibold">${(item.price * optimisticQty).toFixed(2)}</span>
</div>
)
}Common Mistakes
- Not handling rollback on error — if the server fails,
useOptimisticreverts automatically, but you must ensure the real state does not include the failed item - Using
useOptimisticwithout a pending visual indicator — users should know the action is in-flight - Applying optimistic updates to destructive actions like "Delete Account" — use confirmation dialogs for irreversible operations
- Forgetting that
useOptimisticis a Client Component feature — you need'use client'
Best Practices
- Always show a visual "pending" state (opacity, spinner, or label) on optimistically updated items
- Use optimistic updates only for lightweight, reversible actions (likes, quantity changes, toggles)
- Pair with Server Actions for the cleanest code — the form's
actionprop handles pending state automatically - Add error toasts or notifications when server operations fail so users know the action did not persist
Key Takeaways
useOptimisticis a React 19 hook that shows an optimistic UI immediately while an async operation runs- The hook takes actual state and an optional reducer; the optimistic state reverts once the async action settles
- Call
addOptimistic(value)inside an async event handler or Server Action to trigger the optimistic update pending: trueflags on optimistic items let you style in-flight updates differently (e.g., greyed out)- This pattern eliminates the need for manual loading states on frequent actions like likes and cart updates
useOptimisticis specifically designed for use with React Server Actions and the formactionprop- Always test failure paths — verify the UI correctly reflects reality when server operations fail
- Irreversible or high-stakes actions should use confirmation dialogs, not optimistic updates
Advertisement