useOptimistic is genuinely one of the nicer additions to React for making an app feel fast, updating the UI immediately while the real request happens in the background. It's also very easy to implement in a way that only handles the success path, leaving the UI confidently showing something that never actually happened once the request fails.
The Setup That Looks Complete
// components/TodoItem.tsx
'use client';
import { useOptimistic } from 'react';
import { toggleComplete } from '@/actions/todos';
export function TodoItem({ todo }: { todo: Todo }) {
const [optimisticTodo, setOptimisticTodo] = useOptimistic(todo);
async function handleToggle() {
setOptimisticTodo({ ...todo, completed: !todo.completed });
await toggleComplete(todo.id);
}
return (
<div onClick={handleToggle}>
<span className={optimisticTodo.completed ? 'line-through' : ''}>
{optimisticTodo.title}
</span>
</div>
);
}
This is close to the exact example from React's own docs, and it demonstrates the concept well. It also quietly assumes toggleComplete always succeeds, and doesn't do anything if it doesn't.
What Actually Happens When the Server Action Fails
useOptimistic's whole mechanism is that the optimistic value automatically reverts once the surrounding transition completes and the component re-renders with real data from the server. This part genuinely works as documented. The gap is what "real data from the server" actually means if toggleComplete threw an error, or returned a typed failure result, and nothing in this component ever surfaces that failure to the user.
If toggleComplete fails silently, network error, database error, an authorization check rejecting the change, the optimistic state briefly shows the toggled state, then reverts once the transition resolves, since the underlying todo prop never actually changed. From the user's perspective, they clicked a checkbox, watched it check, and then watched it silently un-check itself a moment later, with zero explanation of what happened or why. That's a genuinely confusing experience, and it's worse than not having optimistic UI at all, since it actively shows a false success before reverting rather than just taking a normal moment to update.
The Fix: Actually Handle the Failure Case
'use client';
import { useOptimistic, useState } from 'react';
import { toggleComplete } from '@/actions/todos';
export function TodoItem({ todo }: { todo: Todo }) {
const [optimisticTodo, setOptimisticTodo] = useOptimistic(todo);
const [error, setError] = useState<string | null>(null);
async function handleToggle() {
setError(null);
setOptimisticTodo({ ...todo, completed: !todo.completed });
const result = await toggleComplete(todo.id);
if (!result.success) {
setError('Could not update. Try again.');
// optimisticTodo reverts automatically once this transition resolves,
// since the real todo prop never actually changed
}
}
return (
<div>
<div onClick={handleToggle}>
<span className={optimisticTodo.completed ? 'line-through' : ''}>
{optimisticTodo.title}
</span>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
</div>
);
}
// actions/todos.ts
'use server';
export async function toggleComplete(id: string) {
try {
const todo = await Todo.findById(id);
await Todo.findByIdAndUpdate(id, { completed: !todo.completed });
revalidatePath('/todos');
return { success: true };
} catch (error) {
return { success: false, message: 'Failed to update' };
}
}
The optimistic revert still happens automatically, that part of useOptimistic was never broken. What's added is actually telling the user why it reverted, instead of leaving them to notice a checkbox silently un-checking itself and wonder if they imagined clicking it in the first place.
A Subtler Version of the Same Gap: Partial Failure in a List
// ❌ Doesn't distinguish which specific item's update actually failed
async function handleBulkComplete(ids: string[]) {
ids.forEach((id) => setOptimisticTodos(prev =>
prev.map(t => t.id === id ? { ...t, completed: true } : t)
));
await Promise.all(ids.map(id => toggleComplete(id)));
// if one of these failed, which one? the UI has no idea
}
When an optimistic update covers multiple items and only some of the underlying requests fail, Promise.all rejecting on the first failure, or resolving with a mix of success and failure results, needs to be handled per-item, not just as one collective success or failure. Otherwise a user bulk-completing five tasks might see all five check off, with no indication that one of them silently failed to actually save, until they refresh and find it unchecked again with zero explanation.
// ✅ Tracks and surfaces which specific items actually failed
async function handleBulkComplete(ids: string[]) {
ids.forEach((id) => setOptimisticTodos(prev =>
prev.map(t => t.id === id ? { ...t, completed: true } : t)
));
const results = await Promise.allSettled(ids.map(id => toggleComplete(id)));
const failedIds = ids.filter((id, i) => results[i].status === 'rejected');
if (failedIds.length > 0) {
setError(`${failedIds.length} item(s) failed to update`);
}
}
The Actual Rule
Optimistic UI needs a real plan for the failure path, not just the happy path the demo shows. useOptimistic correctly reverts the visual state once real data doesn't match the optimistic guess, that mechanism works as intended. What it doesn't do automatically is explain to the user why something they just watched happen apparently didn't, and building that explanation in is the actual work optimistic UI adds on top of a normal loading state, not something that comes free with the hook itself.
If you've got useOptimistic in production, check what actually happens on a genuinely failed request, not just what the happy path demo shows. Kill your network connection mid-request and watch what the user actually sees. If it's a silent revert with no explanation, that's worth fixing. Drop what you find in the comments.
Get the templates: https://pixelanas.gumroad.com
Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751
Top comments (0)