The most underused React 19 hook is useOptimistic. Most teams still reach for local state + loading spinners when they could ship instant UI.
Here's the hook, the patterns, and the edge cases that bite in production.
What useOptimistic Solves
// Old way — users see lag
async function addTodo(text: string) {
setLoading(true);
const newTodo = await createTodo(text); // 200-800ms wait
setTodos(prev => [...prev, newTodo]);
setLoading(false);
}
With useOptimistic:
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(currentTodos, newText: string) => [
...currentTodos,
{ id: crypto.randomUUID(), text: newText, pending: true }
]
);
async function addTodo(text: string) {
addOptimisticTodo(text); // Instant UI update
const newTodo = await createTodo(text); // Background
setTodos(prev => [...prev, newTodo]);
}
Item appears instantly. If the request fails, the optimistic update rolls back automatically.
The API
const [optimisticState, dispatchOptimistic] = useOptimistic(
state, // real state (from server/parent)
updateFn // (currentState, action) => nextOptimisticState
);
React automatically reverts optimisticState back to state when the async action finishes — success or failure. No manual rollback.
Pattern 1: Form Actions
function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state, text: string) => [
...state,
{ id: crypto.randomUUID(), text, completed: false, pending: true }
]
);
async function formAction(formData: FormData) {
addOptimisticTodo(formData.get("todo") as string);
await saveTodo(formData.get("todo") as string);
}
return (
<>
<ul>
{optimisticTodos.map(todo => (
<li key={todo.id} style={{ opacity: todo.pending ? 0.6 : 1 }}>
{todo.text}
</li>
))}
</ul>
<form action={formAction}>
<input name="todo" /><button type="submit">Add</button>
</form>
</>
);
}
Pattern 2: Likes / Toggles
const [optimistic, dispatch] = useOptimistic(
{ liked, count },
(current) => ({
liked: !current.liked,
count: current.liked ? current.count - 1 : current.count + 1,
})
);
async function handleLike() {
dispatch(null); // instant toggle
const result = await toggleLike(postId);
setLike(result);
}
The Critical Rollback Rule
Automatic rollback only fires when the async action throws. Silent failures stick:
// BAD — optimistic state gets stuck
async function save(data) {
const json = await fetch(...).then(r => r.json());
if (!json.success) return json.error; // returns, doesn't throw
}
// GOOD — rollback fires
async function save(data) {
const res = await fetch(...);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
return res.json();
}
Error State Pattern
async function submitComment(formData: FormData) {
const text = formData.get("comment") as string;
setError(null);
addOptimisticComment(text);
try {
const comment = await postComment(postId, text);
setComments(prev => [...prev, comment]);
} catch {
setError("Failed to post. Try again.");
// useOptimistic auto-reverts the optimistic entry
}
}
When NOT to Use It
- Server response changes data significantly — optimistic placeholder flashes when replaced
- Conflicts are likely — collaborative editing, inventory, financial writes
- Action is irreversible — deletions, charges, sent emails — show confirmation instead
The Real Win
useOptimistic doesn't make your server faster — it makes your app feel faster by showing results before the server responds. For most CRUD operations that's the only latency that matters to users.
Use for: adds, likes, toggles, reorders, soft deletes.
Skip for: irreversible actions, conflict-prone writes, server-computed transforms.
React 19 + Next.js Server Actions + optimistic UI pre-wired? The AI SaaS Starter Kit ships with all of it — skip the 40-hour setup.
Top comments (0)