DEV Community

Carlos Oliva Pascual
Carlos Oliva Pascual

Posted on • Originally published at stacknotice.com

React useOptimistic: Optimistic UI Patterns That Actually Work (2026)

The problem with most web UIs is the gap between user action and visible feedback. A user clicks "like" and waits 200-400ms for the server to respond before the button changes. That delay reads as slowness even when the server is fast. The network round-trip is the ceiling.

Optimistic UI inverts this: assume the operation will succeed, update the UI immediately, then reconcile with the server response when it arrives. If the server fails, roll back. React 19's useOptimistic hook gives you this pattern with minimal boilerplate and automatic rollback built in.

The API

const [optimisticState, addOptimistic] = useOptimistic(
  state,          // the current "real" state — synced from server
  updateFn,       // (currentState, optimisticValue) => newOptimisticState
)
Enter fullscreen mode Exit fullscreen mode
  • optimisticState — during a pending transition, reflects the optimistic update. Once the transition completes, it reverts to state
  • addOptimistic(value) — triggers an optimistic update, must be called inside startTransition

Pattern 1: Like Button

'use client'

import { useOptimistic, useTransition } from 'react'
import { toggleLike } from '@/actions/likes'

type LikeState = { liked: boolean; count: number }

export function LikeButton({ postId, initialLiked, initialCount }: {
  postId: string
  initialLiked: boolean
  initialCount: number
}) {
  const [isPending, startTransition] = useTransition()
  const [optimisticState, addOptimistic] = useOptimistic<LikeState>(
    { liked: initialLiked, count: initialCount },
    (current) => ({
      liked: !current.liked,
      count: current.liked ? current.count - 1 : current.count + 1,
    })
  )

  function handleToggle() {
    startTransition(async () => {
      addOptimistic('toggle')      // updates UI immediately
      await toggleLike({ postId }) // syncs with server
    })
  }

  return (
    <button onClick={handleToggle} disabled={isPending}>
      <Heart className={cn('h-4 w-4', optimisticState.liked && 'fill-red-500 text-red-500')} />
      <span>{optimisticState.count}</span>
    </button>
  )
}
Enter fullscreen mode Exit fullscreen mode

The user clicks, the heart fills instantly. If the server call fails, optimisticState reverts automatically.

Pattern 2: Optimistic List Add

'use client'

import { useOptimistic, useTransition, useRef } from 'react'
import { addComment } from '@/actions/comments'

type Comment = {
  id: string
  text: string
  author: string
  createdAt: Date
  pending?: boolean
}

export function CommentThread({ postId, initialComments, currentUser }: {
  postId: string
  initialComments: Comment[]
  currentUser: { name: string }
}) {
  const [isPending, startTransition] = useTransition()
  const formRef = useRef<HTMLFormElement>(null)

  const [optimisticComments, addOptimisticComment] = useOptimistic<
    Comment[],
    { text: string }
  >(
    initialComments,
    (current, newComment) => [
      ...current,
      {
        id: `optimistic-${Date.now()}`,
        text: newComment.text,
        author: currentUser.name,
        createdAt: new Date(),
        pending: true,
      },
    ]
  )

  function handleSubmit(formData: FormData) {
    const text = formData.get('text') as string
    if (!text.trim()) return

    startTransition(async () => {
      addOptimisticComment({ text })
      formRef.current?.reset()
      await addComment({ postId, text })
    })
  }

  return (
    <div className="space-y-4">
      {optimisticComments.map((comment) => (
        <div key={comment.id} className={cn('rounded-lg border p-3', comment.pending && 'opacity-60')}>
          <span className="font-medium text-sm">{comment.author}</span>
          {comment.pending && <span className="text-xs text-muted-foreground ml-2">Sending...</span>}
          <p className="mt-1 text-sm">{comment.text}</p>
        </div>
      ))}

      <form ref={formRef} action={handleSubmit} className="flex gap-2">
        <Input name="text" placeholder="Add a comment..." className="flex-1" />
        <Button type="submit" disabled={isPending}>Post</Button>
      </form>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

The comment appears instantly with opacity-60 and a "Sending..." label. Once the server responds, the real comment replaces the optimistic one.

Pattern 3: Optimistic Delete

export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
  const [isPending, startTransition] = useTransition()

  const [optimisticTodos, removeOptimistic] = useOptimistic<Todo[], string>(
    initialTodos,
    (current, idToRemove) => current.filter((todo) => todo.id !== idToRemove)
  )

  function handleDelete(id: string) {
    startTransition(async () => {
      removeOptimistic(id)     // remove from UI immediately
      await deleteTodo({ id }) // tell the server
    })
  }

  return (
    <ul>
      {optimisticTodos.map((todo) => (
        <li key={todo.id} className="flex items-center justify-between p-3 border rounded-lg">
          <span>{todo.title}</span>
          <Button variant="ghost" size="icon" onClick={() => handleDelete(todo.id)}>
            <Trash2 className="h-4 w-4" />
          </Button>
        </li>
      ))}
    </ul>
  )
}
Enter fullscreen mode Exit fullscreen mode

Pattern 4: Optimistic Toggle

export function TodoItem({ todo }: { todo: Todo }) {
  const [isPending, startTransition] = useTransition()

  const [optimisticTodo, updateOptimistic] = useOptimistic<Todo, Partial<Todo>>(
    todo,
    (current, patch) => ({ ...current, ...patch })
  )

  function handleToggle(checked: boolean) {
    startTransition(async () => {
      updateOptimistic({ completed: checked })
      await toggleTodo({ id: todo.id, completed: checked })
    })
  }

  return (
    <div className="flex items-center gap-3">
      <Checkbox
        checked={optimisticTodo.completed}
        onCheckedChange={handleToggle}
        disabled={isPending}
      />
      <span className={cn('text-sm', optimisticTodo.completed && 'line-through text-muted-foreground')}>
        {optimisticTodo.title}
      </span>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

The generic Partial<Todo> pattern makes this reusable for any field on the todo.

Error Handling and Rollback

useOptimistic rolls back automatically when the transition fails. But the user needs feedback:

function handleToggle(checked: boolean) {
  startTransition(async () => {
    updateOptimistic({ completed: checked })
    try {
      await toggleTodo({ id: todo.id, completed: checked })
    } catch {
      toast.error('Failed to update. Please try again.')
      // optimisticTodo already rolled back by React
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

TypeScript Typing

// Single type for action
useOptimistic<Comment[], { text: string }>(...)

// Union of action types
useOptimistic<TodoList, 'toggle' | 'delete' | 'reorder'>(...)

// Generic patch (most flexible)
useOptimistic<Todo, Partial<Todo>>(...)
Enter fullscreen mode Exit fullscreen mode

The updateFn receives (currentState: State, action: Action) => State. TypeScript will catch mismatches at the call site.

When NOT to Use useOptimistic

Avoid for:

  • Permanent deletes without an undo mechanism
  • Financial transactions — never show a balance change before server confirms
  • Inventory or seat reservations — race conditions mean the server might reject
  • Actions with side effects visible to other users (sending emails, external APIs)

Good for: Likes, reactions, read status, todo toggles, comment submission, list reordering.

useOptimistic vs React Query

If you're using TanStack Query, it has onMutate rollback:

useMutation({
  mutationFn: toggleLike,
  onMutate: async (variables) => {
    await queryClient.cancelQueries({ queryKey: ['likes', postId] })
    const previous = queryClient.getQueryData(['likes', postId])
    queryClient.setQueryData(['likes', postId], (old) => ({ ...old, liked: !old.liked }))
    return { previous }
  },
  onError: (err, variables, context) => {
    queryClient.setQueryData(['likes', postId], context?.previous)
  },
})
Enter fullscreen mode Exit fullscreen mode
  • useOptimistic — built into React, works natively with Server Actions. Best for App Router workflows.
  • React Query onMutate — integrates with the query cache, survives component remounts. Better for REST/GraphQL with a query cache.

Quick Reference

// Import
import { useOptimistic, useTransition } from 'react'

// Hook
const [optimisticState, addOptimistic] = useOptimistic(
  realState,
  (currentState, action) => newState
)

// Always inside startTransition
const [isPending, startTransition] = useTransition()
function handleAction() {
  startTransition(async () => {
    addOptimistic(actionValue)   // immediate UI update
    await serverAction(input)    // async server call
  })
}

// Common patterns:
// List add   → [...current, newItem]
// List delete → current.filter(item => item.id !== id)
// Toggle     → { ...current, field: !current.field }
// Patch      → { ...current, ...partialUpdate }

// Rollback is automatic on failure.
// Show error feedback manually with try/catch.
Enter fullscreen mode Exit fullscreen mode

The mental model: useOptimistic is a view layer only. It doesn't touch the server, doesn't manage cache, doesn't retry. Its job is to show the user what you expect the world to look like while the real operation completes.


Full article at stacknotice.com/blog/react-useoptimistic-guide-2026

Top comments (0)