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
)
-
optimisticState— during a pending transition, reflects the optimistic update. Once the transition completes, it reverts tostate -
addOptimistic(value)— triggers an optimistic update, must be called insidestartTransition
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>
)
}
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>
)
}
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>
)
}
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>
)
}
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
}
})
}
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>>(...)
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)
},
})
- 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.
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)