Kazi Rahamatullah
Kazi Rahamatullah
AboutProjectsBlogContact
UsesBooks
ResumeView CV
Kazi Rahamatullah

© Copyright 2026 Kazi Rahamatullah

AboutProjectsBlogBooksUses
Twitter/XGitHubProduct HuntCodeSandbox
Back to Blog
ReactReact 19Design PatternsuseOptimisticUX

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.

Jul 4, 202616 min read

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:

  1. Core concept — instant feedback, assumed success, reconciliation
  2. useOptimistic anatomy — parameters, return values, update function
  3. Implementation steps — seven-step recipe with startTransition
  4. Examples — likes, chat messages, shopping cart
  5. Error handling & rollback — try/catch, resync, user feedback
  6. Use cases — social, chat, cart, collaboration, voting, AI streams
  7. Pitfalls & anti-patterns — when optimism hurts more than it helps

Note

Optimistic UI is a UX pattern, not a substitute for fast APIs. Users still need accurate feedback when requests fail — the pattern assumes success temporarily while you handle the unhappy path in parallel.

Quick index

#Section
1Core concept
2useOptimistic hook anatomy
3Implementation steps
4Example: social likes
5Example: chat messages
6Example: shopping cart
7Error handling & rollback
8Practical use cases
9Pitfalls & anti-patterns

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

plaintext
User taps Like
  → spinner (150–400ms)
  → count updates

With optimism — instant feedback

plaintext
User taps Like
  → count updates immediately (0ms perceived)
  → server confirms or rolls back in background
IdeaMeaning
Instant interface updatesUI reflects the user's intent before the round-trip completes
Assume server successDefault path: the mutation will succeed (most of the time)
Increased perceived speedApp feels native even on slow networks
UX pattern for engagementReduces friction in likes, sends, cart adds — keeps users in flow
ReconciliationAlign optimistic state with real server truth when the action settles

Reconciliation outcomes

OutcomeWhat happens
Confirm on successUpdate real state with server response; optimistic state matches
Rollback on failureReal state unchanged; optimistic overlay drops when transition ends

Tip

Optimistic UI works best when success rate is high (90%+) and the cost of a brief wrong state is low — a like count off by one for 300ms is fine; a payment confirmation is not.

Warning

Never use pure optimism for irreversible or high-stakes actions — payments, account deletion, prescription orders — without explicit confirmation. Optimism suits recoverable, low-risk interactions.

Back to index


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.

tsx
const [optimisticState, addOptimistic] = useOptimistic(realState, updateFn)

Parameters

ParameterRole
Current stateReal source of truth — usually from useState, props, or server data
Update functionPure 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

ReturnRole
Optimistic stateValue to render in JSX — includes pending optimistic changes
addOptimisticTrigger function — call inside startTransition or an Action
tsx
'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>
  )
}

Note

While a transition is pending, optimisticState may diverge from realState. When the transition completes, React resyncs optimistic state to match real state unless you updated real state during the action.

Performance

useOptimistic runs inside React's transition system — updates are non-blocking and can be interrupted. Prefer it over manually cloning state in useState for pending flags — less boilerplate and correct reconciliation semantics.

Back to index


3. Implementation steps

Follow this seven-step recipe for every optimistic interaction.

StepAction
1Initialize real state with useState (or receive from props / server)
2Setup useOptimistic with real state and a pure updater function
3Render optimistic state in JSX — not the raw real state
4Wrap handler in startTransition or Action — required for optimism to apply
5Call addOptimistic for the immediate UI update
6Execute async server call — fetch, Server Action, mutation
7Update real state on success to sync with server truth

Step-by-step skeleton

tsx
'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>
  )
}

Warning

Step 4 is mandatory. Calling addOptimistic outside startTransition or a React Action will not behave correctly — the optimistic layer expects a concurrent transition context.

Tip

Pair with useTransition when you need an explicit pending indicator: const [isPending, startTransition] = useTransition() — show a subtle spinner on the send button while the server round-trip completes.

Back to index


4. Example: social likes

Likes are the canonical optimistic UI case — toggle instantly, reconcile count from server.

tsx
'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>
  )
}

Note

Use aria-pressed on toggle buttons so screen readers reflect the optimistic liked state, not just the pre-click value.

Back to index


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.

tsx
'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>
  )
}

Tip

Show pending styling (reduced opacity, ellipsis) on optimistic messages so users know confirmation is in flight — instant does not mean invisible.

Back to index


6. Example: shopping cart

Cart add/remove feels instant; server validates inventory and returns authoritative line items.

tsx
'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>
  )
}

Performance

For cart pages backed by TanStack Query, you can combine useOptimistic for instant UI with onSettled invalidation — see TanStack Query for cache-level optimistic updates when the cart spans multiple routes.

Back to index


7. Error handling & rollback

Optimistic UI fails when teams plan only for success. Every optimistic path needs an unhappy path.

TechniquePurpose
try/catch around server callDetect network and HTTP failures
Do not update real state on errorOptimistic overlay reverts when transition completes
Explicit state reset (optional)Force known previous snapshot in catch for complex merges
User feedbackToast, inline error, retry button

Rollback pattern

tsx
'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

plaintext
addOptimistic(next)     → UI shows next immediately
await server()          → pending transition
success: setReal(server) → optimistic === real
failure: real unchanged  → transition ends → optimistic reverts to real

Warning

If you partially update real state before the server responds, rollback becomes manual — you must restore the previous snapshot in catch. Keep real state updates after confirmed success when possible.

Note

TanStack Query mutations use **onMutate / onError / onSettled** for the same rollback semantics at the cache layer. Choose useOptimistic` for local component state; choose Query optimistic updates when multiple components read the same server cache.

Tip

Always give users a retry affordance after rollback — disabled button + "Try again" beats a silent revert that looks like a bug.

Back to index


8. Practical use cases

Use caseOptimistic actionRollback trigger
Social likes & reactionsIncrement count, toggle heart4xx/5xx, rate limit
Chat messagingAppend message with pending flagSend failure, offline
Shopping cart add/removeUpdate qty and line itemsOut of stock, auth expired
Collaborative editingShow local keystrokes before saveConflict, version mismatch
Voting & pollsIncrement vote countDuplicate vote, closed poll
AI response visualizationStream placeholder or partial tokensModel error, timeout

Collaborative editing sketch

tsx
// 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 optimistic

AI streaming sketch

tsx
// 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 messages

Interview Answer

"When would you use useOptimistic vs TanStack Query optimistic updates?"

Use useOptimistic when optimistic state is local to one component tree — a like button, a chat composer, a single-page form. Use Query onMutate when the same server data is read across routes or components from a shared cache — cart badge + cart page + checkout summary all need the same optimistic patch.

Back to index


9. Pitfalls & anti-patterns

Anti-patternProblemFix
Assume 100% server successNo rollback, silent wrong datatry/catch + user feedback on every mutation
Neglect rollback mechanismsUI stuck showing failed actionKeep real state pristine until success
Hide fundamentally slow UXOptimism masks 5s API — users still wait elsewhereFix backend, cache, edge; optimism is polish
Update outside transitionsaddOptimistic without startTransitionAlways wrap in transition or Server Action
Optimistic irreversible actionsUser thinks payment succeededConfirm before charge; no optimism on money
Duplicate optimistic + manual cloneTwo sources of pending truthPick useOptimistic OR manual flags, not both

Warning

Do not use optimistic UI to paper over broken infrastructure. If p95 latency is multiple seconds, fix caching, database indexes, or edge placement first — instant UI that reverts constantly erodes trust faster than a honest spinner.

Warning

Calling addOptimistic in a regular synchronous event handler without startTransition breaks React 19's reconciliation guarantees. The optimistic value may flash incorrectly or fail to revert.

Performance

Debounce rapid optimistic actions (double-tap like, spam send) with disabled state while isPending from useTransition — prevents queueing conflicting optimistic layers.

Tip

Production checklist

  • Real state in useState / props — single source of truth
  • Pure updater function in useOptimistic
  • Render optimisticState, not raw real state
  • startTransition (or Action) wraps every addOptimistic call
  • try/catch on server call; update real state only on success
  • Pending visual affordance (opacity, ellipsis)
  • Error toast + retry on rollback
  • Reserve optimism for high-success, low-risk actions
  • Query cache optimism when data is shared across routes

Back to index


Summary

PieceRole
Real stateSource of truth — updated after server confirms
useOptimisticTemporary overlay during pending transitions
addOptimisticTrigger instant UI inside a transition
startTransitionRequired wrapper for concurrent reconciliation
RollbackOmit 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.

Share this article

XLinkedInFacebook
Kazi Rahamatullah

Written by

Kazi Rahamatullah

FullStack Developer

X / TwitterGitHubLinkedIn

Subscribe to my newsletter

Stay up to date and get notified when I share new contents.

No spam ever, unsubscribe anytime