Optimistic UI Pattern in React 19
Build instant-feeling UIs with React 19 useOptimistic — hook anatomy, startTransition, likes and chat examples, rollback on failure, cart and voting use cases, and anti-patterns.
Introduction
The Optimistic UI pattern updates the interface immediately — before the server confirms — so users perceive zero latency. React 19 ships useOptimistic to manage temporary UI state that reconciles with the real source of truth when an async action succeeds or rolls back when it fails.
This guide covers:
- Core concept — instant feedback, assumed success, reconciliation
useOptimisticanatomy — parameters, return values, update function- Implementation steps — seven-step recipe with
startTransition - Examples — likes, chat messages, shopping cart
- Error handling & rollback — try/catch, resync, user feedback
- Use cases — social, chat, cart, collaboration, voting, AI streams
- Pitfalls & anti-patterns — when optimism hurts more than it helps
Quick index
1. Core concept
Traditional async UI waits for the network, shows a spinner, then updates — users feel latency even when the server responds in 200ms. Optimistic UI flips the order: render the expected outcome first, send the request in the background, then reconcile.
Without optimism — perceived lag
User taps Like
→ spinner (150–400ms)
→ count updatesWith optimism — instant feedback
User taps Like
→ count updates immediately (0ms perceived)
→ server confirms or rolls back in background| Idea | Meaning |
|---|---|
| Instant interface updates | UI reflects the user's intent before the round-trip completes |
| Assume server success | Default path: the mutation will succeed (most of the time) |
| Increased perceived speed | App feels native even on slow networks |
| UX pattern for engagement | Reduces friction in likes, sends, cart adds — keeps users in flow |
| Reconciliation | Align optimistic state with real server truth when the action settles |
Reconciliation outcomes
| Outcome | What happens |
|---|---|
| Confirm on success | Update real state with server response; optimistic state matches |
| Rollback on failure | Real state unchanged; optimistic overlay drops when transition ends |
2. useOptimistic hook anatomy
React 19's useOptimistic pairs a real state (source of truth) with a temporary optimistic overlay while a transition is pending.
const [optimisticState, addOptimistic] = useOptimistic(realState, updateFn)Parameters
| Parameter | Role |
|---|---|
| Current state | Real source of truth — usually from useState, props, or server data |
| Update function | Pure function (currentState, optimisticValue) => nextOptimisticState |
The update function must be pure — no side effects, no fetch calls. It only describes how to merge the optimistic payload into the current snapshot.
Return values
| Return | Role |
|---|---|
| Optimistic state | Value to render in JSX — includes pending optimistic changes |
addOptimistic | Trigger function — call inside startTransition or an Action |
'use client'
import { useOptimistic } from 'react'
type Todo = { id: string; text: string; pending?: boolean }
function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(todos, (currentTodos, newTodo: Todo) => [
...currentTodos,
{ ...newTodo, pending: true },
])
// Render optimisticTodos — not todos — so pending items appear instantly
return (
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id} className={todo.pending ? 'opacity-60' : ''}>
{todo.text}
</li>
))}
</ul>
)
}3. Implementation steps
Follow this seven-step recipe for every optimistic interaction.
| Step | Action |
|---|---|
| 1 | Initialize real state with useState (or receive from props / server) |
| 2 | Setup useOptimistic with real state and a pure updater function |
| 3 | Render optimistic state in JSX — not the raw real state |
| 4 | Wrap handler in startTransition or Action — required for optimism to apply |
| 5 | Call addOptimistic for the immediate UI update |
| 6 | Execute async server call — fetch, Server Action, mutation |
| 7 | Update real state on success to sync with server truth |
Step-by-step skeleton
'use client'
import { useOptimistic, useState, startTransition } from 'react'
type Item = { id: string; label: string }
export function OptimisticList() {
// Step 1 — real state
const [items, setItems] = useState<Item[]>([])
// Step 2 — optimistic pair
const [optimisticItems, addOptimisticItem] = useOptimistic(items, (current, newItem: Item) => [...current, newItem])
async function handleAdd(label: string) {
const temp: Item = { id: crypto.randomUUID(), label }
startTransition(async () => {
// Step 5 — immediate UI
addOptimisticItem(temp)
// Step 6 — server
const saved = await fetch('/api/items', {
method: 'POST',
body: JSON.stringify({ label }),
}).then((r) => r.json())
// Step 7 — sync real state
setItems((prev) => [...prev, saved])
})
}
// Step 3 — render optimistic
return (
<ul>
{optimisticItems.map((item) => (
<li key={item.id}>{item.label}</li>
))}
<button type="button" onClick={() => handleAdd('New item')}>
Add
</button>
</ul>
)
}4. Example: social likes
Likes are the canonical optimistic UI case — toggle instantly, reconcile count from server.
'use client'
import { useOptimistic, useState, startTransition } from 'react'
type LikeState = {
count: number
likedByMe: boolean
}
async function toggleLikeApi(postId: string, liked: boolean): Promise<LikeState> {
const res = await fetch(`/api/posts/${postId}/like`, {
method: liked ? 'POST' : 'DELETE',
})
if (!res.ok) throw new Error('Like failed')
return res.json()
}
export function LikeButton({ postId, initial }: { postId: string; initial: LikeState }) {
const [likes, setLikes] = useState(initial)
const [optimisticLikes, addOptimisticLike] = useOptimistic(likes, (current, delta: number) => ({
count: Math.max(0, current.count + delta),
likedByMe: delta > 0,
}))
function handleClick() {
const nextLiked = !optimisticLikes.likedByMe
const delta = nextLiked ? 1 : -1
startTransition(async () => {
addOptimisticLike(delta)
try {
const serverState = await toggleLikeApi(postId, nextLiked)
setLikes(serverState)
} catch {
// Real state unchanged — optimistic overlay reverts when transition ends
}
})
}
return (
<button
type="button"
aria-pressed={optimisticLikes.likedByMe}
onClick={handleClick}
className={optimisticLikes.likedByMe ? 'text-red-500' : ''}>
♥ {optimisticLikes.count}
</button>
)
}5. Example: chat messages
Chat apps append the outgoing message immediately with a pending style, then replace the temp id with the server id on success.
'use client'
import { useOptimistic, useState, startTransition } from 'react'
type Message = {
id: string
text: string
author: 'me' | 'them'
pending?: boolean
failed?: boolean
}
async function sendMessageApi(text: string): Promise<Message> {
const res = await fetch('/api/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
})
if (!res.ok) throw new Error('Send failed')
return res.json()
}
export function ChatThread({ initialMessages }: { initialMessages: Message[] }) {
const [messages, setMessages] = useState(initialMessages)
const [optimisticMessages, addOptimisticMessage] = useOptimistic(messages, (current, msg: Message) => [
...current,
msg,
])
function handleSend(text: string) {
const tempId = crypto.randomUUID()
const optimisticMsg: Message = { id: tempId, text, author: 'me', pending: true }
startTransition(async () => {
addOptimisticMessage(optimisticMsg)
try {
const saved = await sendMessageApi(text)
setMessages((prev) => [...prev.filter((m) => m.id !== tempId), saved])
} catch {
setMessages((prev) => prev.map((m) => (m.id === tempId ? { ...m, pending: false, failed: true } : m)))
}
})
}
return (
<div className="flex flex-col gap-2">
{optimisticMessages.map((msg) => (
<div
key={msg.id}
className={`max-w-xs rounded px-3 py-2 ${msg.author === 'me' ? 'ml-auto bg-blue-500 text-white' : 'bg-neutral-200'} ${msg.pending ? 'opacity-70' : ''} ${msg.failed ? 'line-through opacity-50' : ''}`}>
{msg.text}
{msg.pending && <span className="ml-1 text-xs">…</span>}
{msg.failed && <span className="ml-1 text-xs">failed</span>}
</div>
))}
<ChatInput onSend={handleSend} />
</div>
)
}
function ChatInput({ onSend }: { onSend: (text: string) => void }) {
return (
<form
onSubmit={(e) => {
e.preventDefault()
const fd = new FormData(e.currentTarget)
const text = String(fd.get('text') ?? '').trim()
if (!text) return
onSend(text)
e.currentTarget.reset()
}}>
<input name="text" placeholder="Type a message…" className="flex-1 rounded border px-3 py-2" />
<button type="submit">Send</button>
</form>
)
}6. Example: shopping cart
Cart add/remove feels instant; server validates inventory and returns authoritative line items.
'use client'
import { useOptimistic, useState, startTransition } from 'react'
type CartItem = { productId: string; name: string; qty: number }
type CartAction = { type: 'add'; productId: string; name: string } | { type: 'remove'; productId: string }
function applyCartAction(items: CartItem[], action: CartAction): CartItem[] {
switch (action.type) {
case 'add': {
const existing = items.find((i) => i.productId === action.productId)
if (existing) {
return items.map((i) => (i.productId === action.productId ? { ...i, qty: i.qty + 1 } : i))
}
return [...items, { productId: action.productId, name: action.name, qty: 1 }]
}
case 'remove':
return items
.map((i) => (i.productId === action.productId ? { ...i, qty: i.qty - 1 } : i))
.filter((i) => i.qty > 0)
default:
return items
}
}
async function syncCart(action: CartAction): Promise<CartItem[]> {
const res = await fetch('/api/cart', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(action),
})
if (!res.ok) throw new Error('Cart sync failed')
return res.json()
}
export function CartProviderDemo({ initialItems }: { initialItems: CartItem[] }) {
const [cart, setCart] = useState(initialItems)
const [optimisticCart, addOptimisticCart] = useOptimistic(cart, applyCartAction)
function mutate(action: CartAction) {
startTransition(async () => {
addOptimisticCart(action)
try {
const serverCart = await syncCart(action)
setCart(serverCart)
} catch {
// Rollback: real cart unchanged; optional toast in section 7
}
})
}
const total = optimisticCart.reduce((sum, i) => sum + i.qty, 0)
return (
<div>
<p className="font-bold">Cart ({total} items)</p>
<ul>
{optimisticCart.map((item) => (
<li key={item.productId} className="flex items-center gap-2">
{item.name} × {item.qty}
<button type="button" onClick={() => mutate({ type: 'remove', productId: item.productId })}>
−
</button>
</li>
))}
</ul>
<button type="button" onClick={() => mutate({ type: 'add', productId: 'sku-1', name: 'Headphones' })}>
Add Headphones
</button>
</div>
)
}7. Error handling & rollback
Optimistic UI fails when teams plan only for success. Every optimistic path needs an unhappy path.
| Technique | Purpose |
|---|---|
| try/catch around server call | Detect network and HTTP failures |
| Do not update real state on error | Optimistic overlay reverts when transition completes |
| Explicit state reset (optional) | Force known previous snapshot in catch for complex merges |
| User feedback | Toast, inline error, retry button |
Rollback pattern
'use client'
import { useOptimistic, useState, startTransition } from 'react'
type VoteState = { optionId: string; votes: number }
export function PollOption({
optionId,
initial,
onVote,
}: {
optionId: string
initial: VoteState
onVote: (id: string) => Promise<VoteState>
}) {
const [vote, setVote] = useState(initial)
const [optimisticVote, addOptimisticVote] = useOptimistic(vote, (_, next: VoteState) => next)
const [error, setError] = useState<string | null>(null)
function handleVote() {
setError(null)
const optimisticNext = { optionId, votes: vote.votes + 1 }
startTransition(async () => {
addOptimisticVote(optimisticNext)
try {
const confirmed = await onVote(optionId)
setVote(confirmed)
} catch (err) {
// Real state stays at `vote` — optimistic resyncs automatically
setError(err instanceof Error ? err.message : 'Vote failed — try again')
showToast('Could not register vote. Please retry.')
}
})
}
return (
<div>
<button type="button" onClick={handleVote}>
{optimisticVote.optionId} ({optimisticVote.votes})
</button>
{error && <p className="text-sm text-red-600">{error}</p>}
</div>
)
}
function showToast(message: string) {
// wire to your toast library — sonner, react-hot-toast, etc.
console.warn(message)
}Reconciliation flow
addOptimistic(next) → UI shows next immediately
await server() → pending transition
success: setReal(server) → optimistic === real
failure: real unchanged → transition ends → optimistic reverts to real8. Practical use cases
| Use case | Optimistic action | Rollback trigger |
|---|---|---|
| Social likes & reactions | Increment count, toggle heart | 4xx/5xx, rate limit |
| Chat messaging | Append message with pending flag | Send failure, offline |
| Shopping cart add/remove | Update qty and line items | Out of stock, auth expired |
| Collaborative editing | Show local keystrokes before save | Conflict, version mismatch |
| Voting & polls | Increment vote count | Duplicate vote, closed poll |
| AI response visualization | Stream placeholder or partial tokens | Model error, timeout |
Collaborative editing sketch
// Optimistic: apply local patch immediately
addOptimisticPatch({ type: 'insert', pos: cursor, text: 'hello' })
// Server: operational transform or CRDT merge
const merged = await syncDocumentPatch(patch)
setDocument(merged) // real state replaces optimisticAI streaming sketch
// Show "thinking…" or partial tokens optimistically while awaiting first chunk
addOptimisticMessage({ id: 'stream', text: '▍', author: 'ai', pending: true })
const stream = await fetch('/api/chat/stream')
// Replace optimistic placeholder as tokens arrive via setState on real messages9. Pitfalls & anti-patterns
| Anti-pattern | Problem | Fix |
|---|---|---|
| Assume 100% server success | No rollback, silent wrong data | try/catch + user feedback on every mutation |
| Neglect rollback mechanisms | UI stuck showing failed action | Keep real state pristine until success |
| Hide fundamentally slow UX | Optimism masks 5s API — users still wait elsewhere | Fix backend, cache, edge; optimism is polish |
| Update outside transitions | addOptimistic without startTransition | Always wrap in transition or Server Action |
| Optimistic irreversible actions | User thinks payment succeeded | Confirm before charge; no optimism on money |
| Duplicate optimistic + manual clone | Two sources of pending truth | Pick useOptimistic OR manual flags, not both |
Summary
| Piece | Role |
|---|---|
| Real state | Source of truth — updated after server confirms |
useOptimistic | Temporary overlay during pending transitions |
addOptimistic | Trigger instant UI inside a transition |
startTransition | Required wrapper for concurrent reconciliation |
| Rollback | Omit real state update on failure; notify user |
The Optimistic UI pattern makes React apps feel instant by assuming success first and reconciling later. React 19's useOptimistic removes manual pending-flag boilerplate — pair it with startTransition, solid error handling, and honest rollback UX.
Next reads: Controlled vs Uncontrolled Pattern for React 19 form Actions, TanStack Query for cache-level optimistic mutations, and React Design Patterns for the full component pattern roadmap.
Subscribe to my newsletter
Stay up to date and get notified when I share new contents.
No spam ever, unsubscribe anytime