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 (2)
The "when not to use it" section is the important guardrail.
I would add one production concern: the server should return the canonical entity, and the UI should reconcile the optimistic placeholder with it, do not assume the optimistic shape is the final result. For example, a new comment may begin with a client-only id:
The response may contain the real id, normalized text, timestamps, counters. Treat the mutation as replacing a placeholder with the server's version.
That also helps with retries and duplicate submissions. Include an idempotency key or client mutation id, and the server can safely collapse repeated requests without making the interface feel slower
This is exactly the distinction that separates toy implementations from production-grade ones—thank you for adding it.
The reconciliation step is the part most guides skip. In the App Router + Server Actions model, revalidatePath handles it automatically: the Server Component re-fetches and the canonical entity from the database replaces the optimistic placeholder wholesale. But the moment you step outside that model (client-side cache, React Query, manual state), you have to reconcile explicitly.
The pattern that works well is carrying the client mutation ID through the full round trip so you can find and replace the placeholder precisely:
const [optimisticComments, dispatch] = useOptimistic(
serverComments,
(current, action) => {
if (action.type === 'add') {
return [
...current,
{
id: action.mutationId, // temporary ID
text: action.text,
pending: true,
},
];
}
}
);
async function handleSubmit(text: string) {
const mutationId = crypto.randomUUID();
startTransition(async () => {
dispatch({
type: 'add',
mutationId,
text,
});
});
}
On the server side, the same mutation ID enables idempotency:
export const addComment = authActionClient
.schema(
z.object({
postId: z.string(),
text: z.string(),
mutationId: z.string().uuid(),
})
)
.action(async ({ parsedInput, ctx }) => {
// Idempotency check — collapse duplicate submissions
const existing = await db.query.comments.findFirst({
where: and(
eq(comments.mutationId, parsedInput.mutationId),
eq(comments.userId, ctx.userId)
),
});
});
The key mental model is to treat the optimistic value as a temporary placeholder rather than a second entity. The optimistic state is a hypothesis; the server response is the source of truth. Instead of appending another item when the request completes, you replace the placeholder with the canonical entity returned by the server.
The idempotency key completes the pattern. Network retries, accidental double-clicks, or repeated submissions become harmless because the server recognizes the mutation ID and returns the existing entity instead of creating a duplicate. Together, optimistic reconciliation and idempotency provide a robust foundation for production-grade optimistic UI.