Users expect interfaces to respond the moment they click, tap, or submit. Every millisecond of visible delay between a user action and a UI change erodes trust, and network round-trips of 200-600 ms on typical connections create a perceptible gap that no loading spinner can fully paper over. React useOptimistic addresses this directly by giving developers a first-party hook for optimistic UI updates, replacing the most common boilerplate pattern, though full server-state management libraries remain valuable for complex caching needs.
Before React 19, writing optimistic updates meant juggling manual setState calls inside try/catch blocks, or leaning on libraries like React Query and its onMutate callback to snapshot and restore cache entries. Both approaches worked but required snapshot logic, rollback setState calls, and careful catch-block coordination to avoid stale or inconsistent UI. React 19.0.0’s useOptimistic hook graduates this pattern into a stable, framework-endorsed tool with built-in rollback semantics tied to React’s transition model.
This article covers the hook’s mechanics in detail, walks through three production-grade patterns (append, toggle, and inline edit), addresses error handling and rollback strategies, and closes with a reusable implementation checklist.
Table of Contents
Prerequisites
- React 19.0.0 or later. Verify with
npm list react.useOptimisticdoes not exist in React 18. - Next.js 14+ (App Router) with React 19. Next.js 15+ ships React 19 by default; Next.js 14 requires manual upgrade. The
useOptimistichook itself is framework-agnostic, but Server Actions andrevalidatePathused in these examples are Next.js-specific. - Node.js ≥ 14.17.0 (required for
crypto.randomUUID()). - Working knowledge of React Server Components, Server Actions,
useTransition, anduseActionState.
The useOptimistic Hook: Signature and Mental Model
Hook Signature Breakdown
The hook accepts two arguments and returns a tuple:
const [optimisticState, addOptimistic] = useOptimistic(state, updateFn);
state is the source of truth, typically the actual data fetched from the server or passed down as a prop. It represents what the UI should display when no optimistic update is in flight.
updateFn is a pure reducer with the signature (currentState, optimisticValue) => newState. It receives the current state and whatever optimistic value gets dispatched, then returns the merged result. No side effects, no async calls, no mutations.
During a pending async transition, optimisticState reflects the optimistically merged state. Once the transition settles, it collapses back to the real state. Your component renders optimisticState instead of state directly.
Calling addOptimistic with an optimistic value triggers the updateFn and immediately updates optimisticState.
const [optimisticMessages, addOptimisticMessage] = useOptimistic(
messages,
(currentMessages, newMessage) => [...currentMessages, newMessage]
);
This minimal declaration takes an array of messages as the source of truth and defines an update function that appends a new message to the current list. The moment addOptimisticMessage fires with a message object, optimisticMessages reflects the appended array before any server response arrives.
How the Lifecycle Works Under the Hood
The lifecycle follows a predictable sequence. First, user interaction triggers addOptimistic within an async transition. Both useTransition and assigning an async function to create the required transition boundary; startTransition is required when attaching the handler to non-form events like onClick. React immediately re-renders the component with optimisticState reflecting the merged value. Meanwhile, the server action runs asynchronously. When the action resolves successfully, the parent state prop updates with real server data, and React reconciles, discarding the optimistic layer. If the action rejects, the transition ends without updating state, so React automatically drops the optimistic entry and the UI reverts to the last known good state, but only when addOptimistic was called inside a transition boundary (via startTransition, useTransition, or a prop).
The optimistic value exists only during the pending async transition. React does not persist it anywhere. Once the parent
stateprop changes (or stays unchanged after an error), React discards it. There is no cleanup function to write and no rollback logic to maintain.
Pattern 1: Optimistic Todo List with Server Actions
Setting Up the Server Action
The server action handles the write operation and triggers revalidation so that subsequent renders receive fresh data:
"use server";
import { revalidatePath } from "next/cache";
export async function addTodo(
formData: FormData
): Promise<{ error: string | null }> {
const text = formData.get("text");
if (typeof text !== "string" || !text.trim()) {
return { error: "Todo text is required." };
}
if (text.length > 500) {
return { error: "Todo text must be 500 characters or fewer." };
}
try {
if (process.env.NODE_ENV === "development") {
await new Promise((resolve) => setTimeout(resolve, 1500));
}
revalidatePath("/todos");
return { error: null };
} catch (e) {
console.error("[addTodo] database write failed:", e);
return { error: "Failed to add todo. Please try again." };
}
}
The revalidatePath call ensures that once the write completes, the server re-fetches the todo list, providing the updated source of truth to the component tree. Note that revalidatePath invalidates the full route segment; consider revalidateTag for finer-grained cache invalidation in larger applications.
Wiring useOptimistic into the Form Component
The form component combines useOptimistic with a action to create the full optimistic append pattern:
"use client";
import { useOptimistic } from "react";
import { addTodo } from "./actions";
type Todo = {
id: string;
text: string;
completed: boolean;
pending?: boolean;
};
export function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(currentTodos, newTodo: Todo) => [...currentTodos, newTodo]
);
async function formAction(formData: FormData) {
const text = formData.get("text");
if (typeof text !== "string" || !text.trim()) return;
addOptimisticTodo({
id: crypto.randomUUID(),
text: text.trim(),
completed: false,
pending: true,
});
await addTodo(formData);
}
return (
<div>
<form action={formAction}>
<input type="text" name="text" required />
<button type="submit">Add Todobutton>
form>
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id} style={{ opacity: todo.pending ? 0.6 : 1 }}>
{todo.text}
{todo.pending && <span> (sending…)span>}
li>
))}
ul>
div>
);
}
When the user submits the form, addOptimisticTodo fires immediately, appending a todo with pending: true. The list re-renders instantly with the new item dimmed and labeled “sending…”. Once addTodo completes and revalidatePath triggers a data refresh, the real todos prop updates, React discards the optimistic entry, and the confirmed todo appears at full opacity without the pending indicator.
Automatic Rollback on Failure
If the Server Action throws an error, the transition ends and React keeps state unchanged. Because optimisticState derives entirely from state plus any in-flight optimistic values, and the transition is no longer pending, React automatically drops the optimistic entry. The todo disappears from the list without any manual rollback code.
This stands in sharp contrast to the pre-useOptimistic approach, where developers had to snapshot state in onMutate, wrap the mutation in try/catch, and explicitly call setState with the snapshot in the catch block. That pattern was error-prone, especially when multiple optimistic updates overlapped.
Pattern 2: Optimistic Like/Unlike Toggle
Toggling Boolean State Optimistically
Toggles introduce a different shape of update function, one that flips a boolean and adjusts a count:
"use client";
import { useOptimistic, useTransition } from "react";
import { toggleLike } from "./actions";
type LikeState = {
liked: boolean;
count: number;
};
export function LikeButton({ postId, initialState }: { postId: string; initialState: LikeState }) {
const [isPending, startTransition] = useTransition();
const [optimisticLike, setOptimisticLike] = useOptimistic(
initialState,
(current, _optimisticValue: null) => ({
liked: !current.liked,
count: current.liked ? current.count - 1 : current.count + 1,
})
);
function handleClick() {
startTransition(async () => {
setOptimisticLike(null);
await toggleLike(postId);
});
}
return (
<button onClick={handleClick} disabled={isPending}>
{optimisticLike.liked ? "❤️" : "🤍"} {optimisticLike.count}
button>
);
}
Here, useTransition wraps the server call so that setOptimisticLike fires within the transition boundary. The update function ignores the optimistic value parameter (passing null) and instead derives the next state entirely from the current state by flipping the boolean and adjusting the count.
Rollback reverts to the current value of the initialState prop; ensure the parent component updates this prop after server confirmation so the rollback target stays current.
Preventing Double-Tap Race Conditions
The isPending flag from useTransition serves as a gate. While the server call is in flight, the button is disabled, preventing duplicate clicks from dispatching contradictory optimistic updates. This matters more for toggles than for append-only lists because each toggle inverts the previous state. Two rapid clicks without a guard would flip the UI twice optimistically while only one server mutation completes, leaving the UI out of sync. Disabling the button (or short-circuiting the click handler when isPending is true) eliminates this race condition entirely.
Pattern 3: Optimistic Inline Editing with Validation
Merging Edited Fields into a Record
Inline editing requires a partial-merge update function that applies changed fields onto an existing record:
"use client";
import { useState, useEffect, useOptimistic, useTransition } from "react";
import { updateRecord } from "./actions";
type TableRecord = {
id: string;
name: string;
email: string;
pending?: boolean;
};
const ALLOWED_FIELDS = new Set<AllowedField>(["name", "email"]);
type AllowedField = "name" | "email";
export function EditableRow({ record }: { record: TableRecord }) {
const [isPending, startTransition] = useTransition();
const [validationError, setValidationError] = useState<string | null>(null);
const [optimisticRecord, setOptimisticRecord] = useOptimistic(
record,
(current, updatedFields: Partial<TableRecord>) => ({
...current,
...updatedFields,
pending: true,
})
);
const [nameValue, setNameValue] = useState(record.name);
const [emailValue, setEmailValue] = useState(record.email);
useEffect(() => {
setNameValue(record.name);
setEmailValue(record.email);
}, [record.name, record.email]);
function handleSave(field: AllowedField, value: string) {
if (!ALLOWED_FIELDS.has(field)) {
console.error("Attempted update of disallowed field:", field);
return;
}
const update: Partial<TableRecord> = { [field]: value };
if (field === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
setValidationError("Invalid email address");
return;
}
setValidationError(null);
startTransition(async () => {
setOptimisticRecord(update);
await updateRecord(record.id, update);
});
}
return (
<tr style={{ opacity: optimisticRecord.pending ? 0.6 : 1 }}>
<td>
<input
value={nameValue}
onChange={(e) => setNameValue(e.target.value)}
onBlur={(e) => handleSave("name", e.target.value)}
/>
td>
<td>
<input
value={emailValue}
onChange={(e) => setEmailValue(e.target.value)}
onBlur={(e) => handleSave("email", e.target.value)}
/>
{validationError && <span className="error">{validationError}span>}
td>
<td aria-live="polite">{optimisticRecord.pending ? "Saving…" : ""}td>
tr>
);
}
The update function spreads updatedFields over the current record and sets pending: true, allowing the row to dim while the server processes the change.
Client-Side Validation Before Optimistic Dispatch
Validation must happen before addOptimistic is called. Dispatching an invalid value means the UI briefly displays incorrect data, even if the server subsequently rejects it. The update function must remain pure, so validation logic belongs in the event handler, not inside the reducer. This keeps the optimistic layer honest: it only shows data the client has already deemed plausible.
Error Handling and Rollback Strategies
Combining useActionState for Error Surfaces
useOptimistic pairs well with useActionState for structured error display after rollback. You must call addOptimistic within the component’s transition boundary, not inside the useActionState action callback. The action callback runs outside the component render cycle, so dispatching optimistic state there will not work as expected.
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(currentTodos, newTodo: Todo) => [...currentTodos, newTodo]
);
const [isPending, startTransition] = useTransition();
const [state, submitAction] = useActionState(
async (_prevState: { error: string | null }, formData: FormData) => {
try {
const result = await addTodo(formData);
return result;
} catch (e) {
console.error("[submitAction] unexpected failure:", e);
return { error: "An unexpected error occurred. Please try again." };
}
},
{ error: null }
);
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const text = formData.get("text");
if (typeof text !== "string" || !text.trim()) return;
startTransition(async () => {
addOptimisticTodo({
id: crypto.randomUUID(),
text: text.trim(),
completed: false,
pending: true,
});
await submitAction(formData);
});
}
{state.error && <p className="error">{state.error}p>}
When the action fails, useActionState captures the returned error object, the optimistic entry vanishes automatically, and the inline error message explains what happened.
Toast Notifications on Failure
Automatic rollback is invisible by default. The optimistic item simply disappears, which confuses users who are not watching the list. Firing a toast notification in the error branch provides explicit communication. Pairing automatic rollback with a visible toast ensures the user understands both that the action failed and that the UI has reverted.
Pairing automatic rollback with a visible toast ensures the user understands both that the action failed and that the UI has reverted.
Retry Patterns
Offering a “Retry” button is appropriate when the failure is transient: an HTTP 503, a network timeout, or a fetch abort. Silent automatic retries suit background sync scenarios but risk duplicating side effects if the server action is not idempotent. Keeping Server Actions idempotent, for instance by using upserts keyed on a client-generated ID, makes any retry strategy safe regardless of how many times it fires.
Performance Considerations and Pitfalls
Avoiding Expensive Update Functions
The update function runs on every render while the transition is pending. If it performs deep cloning of large nested objects or runs in O(n²) time, it will drop frames during the optimistic phase, pushing past the 16 ms per-frame budget. Keeping the function at O(n) or better, and using shallow spreads rather than deep copies, avoids this. For deeply nested state trees, consider flattening the data structure or normalizing it before passing it to useOptimistic.
When NOT to Use useOptimistic
Not every mutation benefits from optimistic UI. Destructive actions like permanent deletes without an undo mechanism should not show instant success, because there is no safe way to roll back a deletion the user has already mentally confirmed. Financial balances and inventory counts are poor candidates too: showing a briefly incorrect balance could cause a user to place an order against a phantom balance. Actions where server-side validation is the sole validation layer (no client-side checks possible) risk flashing data that the server will reject, creating a jarring flash-then-revert experience.
Showing a briefly incorrect balance could cause a user to place an order against a phantom balance.
Production Implementation Checklist
- ☐ Source-of-truth state passed as first argument
- ☐ Update function is pure (no side effects)
- ☐
addOptimisticcalled inside a transition or form action - ☐ Pending visual indicator shown to user
- ☐ Client-side validation runs before dispatch
- ☐ Server Action is idempotent for safe retries
- ☐ Error surface (toast/inline message) wired for rollback scenarios
- ☐ Double-submit prevention via
isPendingor button disable - ☐ Tested with simulated network delay and forced errors
- ☐ Destructive actions either include an undo mechanism or are excluded from optimistic UI
Applying useOptimistic in Existing Code
React 19’s useOptimistic replaces manual snapshot-and-rollback code with a single hook that reverts state automatically when a transition fails. It requires a three-argument hook call and a dispatch inside a transition. The three patterns covered here, append, toggle, and inline edit, serve as starting templates that cover the majority of production mutation scenarios. Each relies on the same core mechanic: a pure update function, a dispatch call inside a transition, and automatic reversion when the server disagrees.
For deeper reference, the React 19 documentation covers useOptimistic alongside useActionState and useFormStatus, which together form a cohesive toolkit for form-driven server interactions. Applying the checklist above to a single form and testing it with artificial latency and forced errors is the fastest way to build confidence in the pattern and measure how much faster the UI feels.

