MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr šŸ”„ tagline

Hey there šŸ‘‹ I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

React useOptimistic: Instant UI Updates

Implement optimistic updates with useOptimistic: forms, lists, and error handling.

ReactuseOptimisticUIFrontend

By MinhVo

Introduction

Optimistic updates are a cornerstone of modern, responsive user interfaces. Instead of showing a loading spinner while waiting for a server to confirm an action, you immediately update the UI to reflect the expected outcome. If the server request succeeds, the UI is already correct. If it fails, you roll back to the previous state. This pattern makes applications feel instantaneous—even on slow networks.

Optimistic UI

React 19 introduces useOptimistic, a built-in hook that simplifies optimistic updates. It manages the temporary optimistic state, handles rollbacks on errors, and integrates seamlessly with React's transition system. In this comprehensive guide, we'll explore useOptimistic from every angle—basic usage, complex list manipulations, form integrations, error handling, and production patterns. You'll learn how to build UIs that feel fast without sacrificing data integrity.

Understanding useOptimistic: Core Concepts

What is useOptimistic?

useOptimistic is a React hook that manages a piece of state that can be temporarily updated to an "optimistic" value while an async operation is in progress. When the operation completes, the state either reverts to the real value (if the optimistic value was wrong) or keeps the optimistic value (if it matches reality).

import { useOptimistic } from 'react'
 
function Counter({ count }: { count: number }) {
  const [optimisticCount, addOptimisticCount] = useOptimistic(
    count,
    (current, increment: number) => current + increment
  )
 
  async function handleIncrement() {
    addOptimisticCount(1) // Immediately shows count + 1
    await updateCountOnServer(count + 1) // Server confirms
    // optimisticCount automatically reverts to new count
  }
 
  return (
    <div>
      <p>Count: {optimisticCount}</p>
      <button onClick={handleIncrement}>Increment</button>
    </div>
  )
}

The Mental Model

Think of useOptimistic as a "preview" layer on top of your real state:

  1. Real state: The actual data from the server (props or fetched data)
  2. Optimistic state: A temporary overlay that shows what the user expects
  3. Sync: When real state updates (server response arrives), optimistic state syncs automatically
Real State:     [A, B, C]
Optimistic:     [A, B, C, D]  ← User added D
                  ↓ Server confirms
Real State:     [A, B, C, D]
Optimistic:     [A, B, C, D]  ← Synced automatically

Optimistic Flow

useOptimistic vs useState

The key difference from useState is that useOptimistic automatically reverts to the real value when the underlying data changes:

// With useState - manual revert required
const [items, setItems] = useState(serverItems)
async function addItem(item) {
  setItems([...items, item]) // Optimistic
  try {
    await saveItem(item)
  } catch {
    setItems(items) // Manual revert!
  }
}
 
// With useOptimistic - automatic revert
const [optimisticItems, addOptimisticItem] = useOptimistic(
  serverItems,
  (current, newItem) => [...current, newItem]
)
async function addItem(item) {
  addOptimisticItem(item) // Optimistic
  await saveItem(item)
  // Automatic revert on error or state change!
}

Architecture and Design Patterns

Basic Update Pattern

The reducer function receives the current optimistic state and an update payload:

const [optimisticMessages, addOptimisticMessage] = useOptimistic(
  messages,
  (state, newMessage: Message) => [...state, { ...newMessage, sending: true }]
)
 
async function sendMessage(text: string) {
  addOptimisticMessage({ id: 'temp', text, sender: 'me' })
  await sendMessageToServer(text)
}

Replace Pattern

Replace an existing item with an updated version:

const [optimisticTodos, updateOptimisticTodo] = useOptimistic(
  todos,
  (state, updatedTodo: Todo) =>
    state.map(todo =>
      todo.id === updatedTodo.id ? { ...updatedTodo, pending: true } : todo
    )
)
 
async function toggleTodo(todo: Todo) {
  updateOptimisticTodo({ ...todo, completed: !todo.completed })
  await updateTodoOnServer(todo.id, { completed: !todo.completed })
}

Remove Pattern

const [optimisticItems, removeOptimisticItem] = useOptimistic(
  items,
  (state, itemId: string) => state.filter(item => item.id !== itemId)
)
 
async function deleteItem(itemId: string) {
  removeOptimisticItem(itemId)
  await deleteItemOnServer(itemId)
}

Step-by-Step Implementation

Todo List with Full CRUD

import { useOptimistic, useRef, useTransition } from 'react'
 
interface Todo {
  id: string
  text: string
  completed: boolean
  pending?: boolean
}
 
function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
  const [optimisticTodos, updateTodos] = useOptimistic(
    initialTodos,
    (state, action: { type: string; payload: any }) => {
      switch (action.type) {
        case 'add':
          return [...state, { ...action.payload, pending: true }]
        case 'toggle':
          return state.map(todo =>
            todo.id === action.payload.id
              ? { ...todo, completed: !todo.completed, pending: true }
              : todo
          )
        case 'delete':
          return state.filter(todo => todo.id !== action.payload.id)
        case 'edit':
          return state.map(todo =>
            todo.id === action.payload.id
              ? { ...todo, text: action.payload.text, pending: true }
              : todo
          )
        default:
          return state
      }
    }
  )
 
  const [isPending, startTransition] = useTransition()
 
  async function addTodo(formData: FormData) {
    const text = formData.get('text') as string
    const newTodo = { id: crypto.randomUUID(), text, completed: false }
 
    startTransition(async () => {
      updateTodos({ type: 'add', payload: newTodo })
      await createTodoOnServer(newTodo)
    })
  }
 
  async function toggleTodo(todo: Todo) {
    startTransition(async () => {
      updateTodos({ type: 'toggle', payload: { id: todo.id } })
      await updateTodoOnServer(todo.id, { completed: !todo.completed })
    })
  }
 
  async function deleteTodo(id: string) {
    startTransition(async () => {
      updateTodos({ type: 'delete', payload: { id } })
      await deleteTodoOnServer(id)
    })
  }
 
  return (
    <div>
      <form action={addTodo}>
        <input name="text" placeholder="Add todo..." required />
        <button type="submit" disabled={isPending}>
          {isPending ? 'Adding...' : 'Add'}
        </button>
      </form>
 
      <ul>
        {optimisticTodos.map(todo => (
          <li
            key={todo.id}
            style={{ opacity: todo.pending ? 0.7 : 1 }}
          >
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => toggleTodo(todo)}
            />
            <span style={{
              textDecoration: todo.completed ? 'line-through' : 'none'
            }}>
              {todo.text}
            </span>
            <button onClick={() => deleteTodo(todo.id)}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  )
}

Like Button with Optimistic Counter

function LikeButton({ postId, initialLikes, isLiked }: LikeButtonProps) {
  const [optimisticLikes, setOptimisticLikes] = useOptimistic(
    { count: initialLikes, liked: isLiked },
    (state, newLiked: boolean) => ({
      count: newLiked ? state.count + 1 : state.count - 1,
      liked: newLiked,
    })
  )
 
  const [isPending, startTransition] = useTransition()
 
  async function toggleLike() {
    startTransition(async () => {
      const newLiked = !optimisticLikes.liked
      setOptimisticLikes(newLiked)
      try {
        await toggleLikeOnServer(postId, newLiked)
      } catch {
        // Revert happens automatically when props update
      }
    })
  }
 
  return (
    <button
      onClick={toggleLike}
      disabled={isPending}
      className={optimisticLikes.liked ? 'liked' : ''}
    >
      {optimisticLikes.liked ? 'ā¤ļø' : 'šŸ¤'} {optimisticLikes.count}
    </button>
  )
}

Shopping Cart with Optimistic Updates

function ShoppingCart({ items }: { items: CartItem[] }) {
  const [optimisticItems, updateCart] = useOptimistic(
    items,
    (state, action: CartAction) => {
      switch (action.type) {
        case 'add': {
          const existing = state.find(i => i.productId === action.productId)
          if (existing) {
            return state.map(i =>
              i.productId === action.productId
                ? { ...i, quantity: i.quantity + 1, updating: true }
                : i
            )
          }
          return [...state, {
            productId: action.productId,
            quantity: 1,
            name: action.name,
            price: action.price,
            updating: true,
          }]
        }
        case 'remove':
          return state.filter(i => i.productId !== action.productId)
        case 'updateQuantity':
          return state.map(i =>
            i.productId === action.productId
              ? { ...i, quantity: action.quantity, updating: true }
              : i
          )
        default:
          return state
      }
    }
  )
 
  const total = optimisticItems.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  )
 
  async function addToCart(product: Product) {
    updateCart({
      type: 'add',
      productId: product.id,
      name: product.name,
      price: product.price,
    })
    await addToCartOnServer(product.id)
  }
 
  async function removeFromCart(productId: string) {
    updateCart({ type: 'remove', productId })
    await removeFromCartOnServer(productId)
  }
 
  async function updateQuantity(productId: string, quantity: number) {
    updateCart({ type: 'updateQuantity', productId, quantity })
    await updateCartQuantityOnServer(productId, quantity)
  }
 
  return (
    <div>
      {optimisticItems.map(item => (
        <div
          key={item.productId}
          style={{ opacity: item.updating ? 0.7 : 1 }}
        >
          <span>{item.name}</span>
          <QuantityControl
            value={item.quantity}
            onChange={qty => updateQuantity(item.productId, qty)}
          />
          <span>${(item.price * item.quantity).toFixed(2)}</span>
          <button onClick={() => removeFromCart(item.productId)}>Remove</button>
        </div>
      ))}
      <div className="total">Total: ${total.toFixed(2)}</div>
    </div>
  )
}

Shopping Cart Pattern

Real-World Use Cases

Use Case 1: Real-Time Chat

function ChatMessages({ messages }: { messages: Message[] }) {
  const [optimisticMessages, addMessage] = useOptimistic(
    messages,
    (state, newMessage: Message) => [...state, { ...newMessage, sending: true }]
  )
 
  const [isPending, startTransition] = useTransition()
 
  async function sendMessage(text: string) {
    const message: Message = {
      id: crypto.randomUUID(),
      text,
      sender: 'me',
      timestamp: Date.now(),
    }
 
    startTransition(async () => {
      addMessage(message)
      try {
        await sendMessageToServer(text)
      } catch (error) {
        // Message will automatically revert when messages prop updates
      }
    })
  }
 
  return (
    <div className="chat">
      <div className="messages">
        {optimisticMessages.map(msg => (
          <div
            key={msg.id}
            className={`message ${msg.sender} ${msg.sending ? 'pending' : ''}`}
          >
            <p>{msg.text}</p>
            {msg.sending && <span className="sending-indicator">Sending...</span>}
          </div>
        ))}
      </div>
      <MessageInput onSend={sendMessage} disabled={isPending} />
    </div>
  )
}

Use Case 2: Inline Editing

function InlineEditor({ item }: { item: EditableItem }) {
  const [optimisticItem, updateItem] = useOptimistic(
    item,
    (current, updates: Partial<EditableItem>) => ({
      ...current,
      ...updates,
      saving: true,
    })
  )
 
  const [isPending, startTransition] = useTransition()
 
  async function handleSave(field: string, value: string) {
    startTransition(async () => {
      updateItem({ [field]: value })
      await updateItemOnServer(item.id, { [field]: value })
    })
  }
 
  return (
    <div className="inline-editor" style={{ opacity: optimisticItem.saving ? 0.7 : 1 }}>
      <EditableText
        value={optimisticItem.title}
        onSave={(value) => handleSave('title', value)}
        disabled={isPending}
      />
      <EditableText
        value={optimisticItem.description}
        onSave={(value) => handleSave('description', value)}
        disabled={isPending}
      />
      {optimisticItem.saving && <span>Saving...</span>}
    </div>
  )
}

Use Case 3: Drag and Drop Reordering

function SortableList({ items }: { items: SortableItem[] }) {
  const [optimisticItems, reorder] = useOptimistic(
    items,
    (state, { fromIndex, toIndex }: { fromIndex: number; toIndex: number }) => {
      const newItems = [...state]
      const [moved] = newItems.splice(fromIndex, 1)
      newItems.splice(toIndex, 0, moved)
      return newItems.map((item, i) => ({ ...item, order: i, reordering: true }))
    }
  )
 
  async function handleDragEnd(fromIndex: number, toIndex: number) {
    reorder({ fromIndex, toIndex })
    await reorderOnServer(
      optimisticItems[fromIndex].id,
      optimisticItems[toIndex].id
    )
  }
 
  return (
    <DragDropContext onDragEnd={handleDragEnd}>
      <Droppable droppableId="list">
        {(provided) => (
          <div ref={provided.innerRef} {...provided.droppableProps}>
            {optimisticItems.map((item, index) => (
              <Draggable key={item.id} draggableId={item.id} index={index}>
                {(provided) => (
                  <div
                    ref={provided.innerRef}
                    {...provided.draggableProps}
                    {...provided.dragHandleProps}
                    style={{
                      ...provided.draggableProps.style,
                      opacity: item.reordering ? 0.7 : 1,
                    }}
                  >
                    {item.title}
                  </div>
                )}
              </Draggable>
            ))}
            {provided.placeholder}
          </div>
        )}
      </Droppable>
    </DragDropContext>
  )
}

Best Practices for Production

  1. Always handle errors: Optimistic updates assume success. Always handle the failure case—either by reverting (automatic with useOptimistic) or showing an error notification.

  2. Use with transitions: Wrap optimistic updates in startTransition to keep the UI responsive during the async operation. This prevents blocking the main thread.

  3. Show visual feedback: Use opacity, animations, or disabled states to indicate that an update is optimistic. Users should know the action is processing.

  4. Don't optimistic-update critical data: For financial transactions or irreversible actions, show a confirmation or loading state instead of optimistic updates.

  5. Combine with error boundaries: If the server rejects an action, the error should be caught and displayed appropriately. Don't let optimistic errors crash the app.

  6. Handle concurrent updates: If the user makes multiple rapid optimistic updates, ensure the server responses are processed correctly. Use request IDs or timestamps.

  7. Test failure scenarios: Write tests that verify the rollback behavior when server requests fail. Ensure the UI returns to the correct state.

  8. Use meaningful IDs: Temporary IDs (like crypto.randomUUID()) should be replaced with server-generated IDs. Handle the ID mapping in your update logic.

Common Pitfalls and Solutions

PitfallImpactSolution
No error handlingUI stuck in optimistic stateCatch errors and revert, or rely on prop updates
Showing loading spinnersDefeats purpose of optimistic updatesUse subtle indicators (opacity, badges)
Optimistic updates for critical actionsData inconsistencyUse explicit confirmation for irreversible actions
Not handling race conditionsStale data displayedUse request ordering or last-write-wins
Temporary IDs not replacedBroken referencesMap temp IDs to real IDs after server response
Missing visual feedbackUsers unsure if action registeredAdd subtle pending indicators

Performance Optimization

// Debounce rapid optimistic updates
function useDebouncedOptimistic<T, U>(
  realState: T,
  reducer: (state: T, action: U) => T,
  delay: number = 300
) {
  const [optimisticState, setOptimistic] = useOptimistic(realState, reducer)
  const timeoutRef = useRef<NodeJS.Timeout>()
 
  function dispatch(action: U) {
    setOptimistic(action)
 
    if (timeoutRef.current) {
      clearTimeout(timeoutRef.current)
    }
 
    timeoutRef.current = setTimeout(() => {
      // Server update happens after debounce
    }, delay)
  }
 
  return [optimisticState, dispatch] as const
}
 
// Batch multiple optimistic updates
function useBatchedOptimistic<T>(
  realState: T[],
  reducer: (state: T[], action: any) => T[]
) {
  const [optimisticState, dispatch] = useOptimistic(realState, reducer)
  const batchRef = useRef<any[]>([])
  const rafRef = useRef<number>()
 
  function batchedDispatch(action: any) {
    batchRef.current.push(action)
 
    if (!rafRef.current) {
      rafRef.current = requestAnimationFrame(() => {
        const actions = batchRef.current
        batchRef.current = []
        rafRef.current = undefined
 
        // Apply all actions at once
        actions.forEach(a => dispatch(a))
      })
    }
  }
 
  return [optimisticState, batchedDispatch] as const
}

Comparison with Alternatives

FeatureuseOptimisticuseState + try/catchReact Query mutationSWR mutate
Automatic revertYesManualYesYes
Built into ReactYesYesNoNo
Rollback on errorAutomaticManualAutomaticAutomatic
Cache integrationManualManualAutomaticAutomatic
TypeScriptExcellentGoodExcellentGood
Learning curveLowLowMediumLow
Bundle size0 KB0 KB13 KB4.3 KB

When to use each:

  • useOptimistic: Simple optimistic updates in React 19+ with fine-grained control
  • React Query mutations: Production apps with complex cache management
  • SWR mutate: Lightweight apps that need optimistic updates with minimal setup
  • useState + try/custom logic: When you need maximum control over the update flow

Advanced Patterns and Techniques

Optimistic Form with Action State

import { useOptimistic, useActionState } from 'react'
 
function CommentForm({ postId }: { postId: string }) {
  const [optimisticComments, addOptimisticComment] = useOptimistic(
    [] as Comment[],
    (state, newComment: Comment) => [...state, newComment]
  )
 
  const [state, submitAction, isPending] = useActionState(
    async (prev: any, formData: FormData) => {
      const text = formData.get('text') as string
      const comment: Comment = {
        id: crypto.randomUUID(),
        text,
        author: 'You',
        createdAt: new Date().toISOString(),
      }
 
      addOptimisticComment(comment)
 
      try {
        await createComment(postId, text)
        return { success: true }
      } catch (error) {
        return { error: 'Failed to post comment' }
      }
    },
    null
  )
 
  return (
    <div>
      <div className="comments">
        {optimisticComments.map(comment => (
          <div key={comment.id} className="comment">
            <strong>{comment.author}</strong>
            <p>{comment.text}</p>
          </div>
        ))}
      </div>
      <form action={submitAction}>
        <textarea name="text" placeholder="Write a comment..." />
        <button type="submit" disabled={isPending}>
          {isPending ? 'Posting...' : 'Post Comment'}
        </button>
      </form>
      {state?.error && <p className="error">{state.error}</p>}
    </div>
  )
}

Multi-Step Optimistic Updates

function OrderFlow({ order }: { order: Order }) {
  const [optimisticOrder, updateOrder] = useOptimistic(
    order,
    (state, step: { action: string; data: any }) => {
      switch (step.action) {
        case 'confirm':
          return { ...state, status: 'confirmed', processing: true }
        case 'ship':
          return { ...state, status: 'shipped', trackingNumber: step.data.trackingNumber, processing: true }
        case 'deliver':
          return { ...state, status: 'delivered', processing: true }
        default:
          return state
      }
    }
  )
 
  async function advanceOrder(action: string, data?: any) {
    updateOrder({ action, data })
    await updateOrderOnServer(order.id, action, data)
  }
 
  return (
    <div>
      <h2>Order #{optimisticOrder.id}</h2>
      <p>Status: {optimisticOrder.status}</p>
      {optimisticOrder.processing && <span>Processing...</span>}
 
      {optimisticOrder.status === 'pending' && (
        <button onClick={() => advanceOrder('confirm')}>Confirm Order</button>
      )}
      {optimisticOrder.status === 'confirmed' && (
        <button onClick={() => advanceOrder('ship', { trackingNumber: 'TRK123' })}>
          Ship Order
        </button>
      )}
    </div>
  )
}

Testing Strategies

import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
 
test('optimistic todo appears immediately', async () => {
  const user = userEvent.setup()
  render(<TodoList initialTodos={[]} />)
 
  const input = screen.getByPlaceholderText('Add todo...')
  await user.type(input, 'New todo')
  await user.click(screen.getByText('Add'))
 
  // Optimistic todo should appear immediately
  expect(screen.getByText('New todo')).toBeInTheDocument()
  expect(screen.getByText('New todo').closest('li')).toHaveStyle({ opacity: 0.7 })
})
 
test('optimistic update reverts on error', async () => {
  server.use(
    http.post('/api/todos', () => {
      return HttpResponse.json({ message: 'Error' }, { status: 500 })
    })
  )
 
  const user = userEvent.setup()
  render(<TodoList initialTodos={[]} />)
 
  await user.type(screen.getByPlaceholderText('Add todo...'), 'Bad todo')
  await user.click(screen.getByText('Add'))
 
  // Optimistic todo appears
  expect(screen.getByText('Bad todo')).toBeInTheDocument()
 
  // After error, it should revert
  await waitFor(() => {
    expect(screen.queryByText('Bad todo')).not.toBeInTheDocument()
  })
})
 
test('multiple rapid optimistic updates', async () => {
  const user = userEvent.setup()
  render(<TodoList initialTodos={[]} />)
 
  // Add multiple todos rapidly
  for (let i = 1; i <= 3; i++) {
    await user.type(screen.getByPlaceholderText('Add todo...'), `Todo ${i}`)
    await user.click(screen.getByText('Add'))
  }
 
  // All should appear optimistically
  expect(screen.getByText('Todo 1')).toBeInTheDocument()
  expect(screen.getByText('Todo 2')).toBeInTheDocument()
  expect(screen.getByText('Todo 3')).toBeInTheDocument()
})

Future Outlook

useOptimistic is part of React 19's suite of hooks designed for async UI patterns. Combined with useActionState, useFormStatus, and Server Actions, it creates a complete framework for form handling with optimistic updates. As the React ecosystem adopts these patterns, expect libraries like React Query to integrate more deeply with useOptimistic, providing cache-aware optimistic updates.

The trend toward optimistic UI is accelerating. Users expect instant feedback, and useOptimistic makes it straightforward to deliver that experience without the complexity of manual state management. Understanding this hook now prepares you for the next generation of React applications.

Conclusion

useOptimistic brings optimistic updates into React as a first-class primitive. By managing temporary state that automatically reverts to real data, it eliminates the boilerplate and error-prone logic of manual optimistic updates.

Key takeaways:

  1. useOptimistic automatically reverts to real state when props change or errors occur
  2. Use with transitions for responsive, non-blocking optimistic updates
  3. Show subtle visual feedback (opacity, badges) for pending optimistic state
  4. Don't use for critical/irreversible actions—use explicit confirmation instead
  5. Combine with Error Boundaries for complete error handling
  6. Test both success and failure scenarios to verify rollback behavior

Start by adding optimistic updates to your most latency-sensitive interactions—like buttons, toggles, and form submissions. The immediate feedback will dramatically improve perceived performance, and useOptimistic ensures data integrity even when things go wrong.