useOptimistic: Instant Feedback and Rollback
Server-driven apps pay a latency tax: the UI cannot reflect a mutation until the network answers.
Optimistic UI pays that tax in the background while showing users an immediate, plausible next
state. React 19’s useOptimistic formalizes the pattern inside the renderer: you supply the
authoritative value (usually from props or a cache) plus a pure reducer that merges a
temporary “draft” action, and React reconciles optimistic output against incoming truth—rolling back
automatically when the base value says your guess was wrong.
API shape
const [optimisticState, addOptimistic] = useOptimistic(value, reducer?)
valueis the committed state—the data you trust when nothing is in flight.reducermaps(current, action) => nextOptimisticState. For simple cases, the reducer can append, toggle flags, or reorder items.optimisticStateis what you render right now, including any pending drafts.addOptimisticenqueues a draft on top ofvalueuntilvalueupdates from the server (or parent) to match reality.
TypeScript generally infers the state and action types from your reducer and base value, which keeps call sites tidy compared to hand-written generic optimistic stores.
Todo list: happy path and failure
type Todo = { id: string; text: string; completed: boolean }
function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state: Todo[], newTodo: Todo) => [...state, newTodo]
)
const [isPending, startTransition] = useTransition()
const handleAddTodo = (text: string) => {
const optimisticTodo: Todo = {
id: `temp-${Date.now()}`,
text,
completed: false,
}
startTransition(async () => {
addOptimisticTodo(optimisticTodo)
try {
await createTodoOnServer({ text })
} catch {
toast.error("Failed to create todo")
}
})
}
return (
<ul>
{optimisticTodos.map((todo) => (
<li
key={todo.id}
style={{ opacity: todo.id.startsWith("temp-") ? 0.7 : 1 }}
>
{todo.text}
</li>
))}
</ul>
)
}
While the Server Action runs, users see the new row immediately. When the server responds with the
canonical list (or a new todo id), props update, the hook’s base value advances, and the temporary
row is replaced—no manual setState synchronization. If the action throws, React drops the
optimistic layer and reverts to the last committed todos you passed in.
Wrapping the flow in startTransition keeps the mutation on the transition priority plane and
exposes isPending if you want buttons disabled or spinners shown. Many teams still render subtle
opacity for temp-* ids so users intuit which rows are not yet durable.
Coordinating with Server Actions
The dominant production pattern is useOptimistic + Server Actions + useTransition:
transitions manage pending time across async work; optimistic state handles the user-visible guess.
After success, refetching or streaming RSC payloads refreshes props; the optimistic hook realigns
automatically.
Important constraints to keep TypeScript and UX honest:
- Reducers must stay pure. No timers, no fetches, no randomness that must match the server.
- Ids and fingerprints should let you reconcile items when the server returns canonical records
(
temp-*versus UUID). - Errors should map to user-meaningful toasts or inline messages; the rollback is automatic, but the explanation is not.
Rollback strategies
Not every failure is binary. Three patterns appear often in mature codebases:
- Full rollback — Accept React’s default: discard the optimistic layer entirely. Best when the client had no valid partial truth (validation failed, conflict, permission error).
- Partial merge — Server returns field-level corrections; parent updates
todoswith merged data so the nextvaluereflects what actually happened. You still avoid manual optimistic clearing—the new props become the base. - Hybrid with notifications — Roll back visually but keep a toast or inline banner explaining what failed and offering retry. Great for flaky networks where users need confidence, not silent snaps.
Choosing among them is product logic, not hook logic. useOptimistic guarantees you will not get
stuck showing a ghost row after the base data says it never existed; how loudly you announce that
correction is up to you.
Compared to hand-rolled useState
Before useOptimistic, teams duplicated the same mistakes: twin arrays (committed vs display),
forgotten resets on navigation, optimistic rows that survive route changes, or double inserts after
refetch. The hook collapses that into one derived view tied to the committed prop stream.
You still need discipline: if parents pass unstable array references each render, memoization
elsewhere suffers. If value lags because caching is stale, optimistic UI can flash
correct-then-wrong. Fix the data layer; the hook will behave.
TypeScript tips
Let inference do its job. When you need explicitness—for example a discriminated union of optimistic actions—define the action type and let the reducer narrow:
type OptimisticAction = { type: "add"; todo: Todo } | { type: "toggle"; id: string };
function reduceTodos(state: Todo[], action: OptimisticAction): Todo[] {
switch (action.type) {
case "add":
return [...state, action.todo];
case "toggle":
return state.map((t) => (t.id === action.id ? { ...t, completed: !t.completed } : t));
}
}
Pair with useTransition’s pending flag for buttons, and with form action/useFormStatus where
appropriate in React 19 forms—those APIs complement optimistic lists without replacing them.
When to skip optimism
Do not optimistically apply mutations that are irreversible or legally sensitive without explicit confirmation. For low-latency but high-risk operations, a fast pending state with no guess is safer. Optimism fits best when the user intent is obvious, the server is likely to accept it, and rollback is indistinguishable from “nothing happened yet.”
Used with the scheduler concepts from earlier sections, useOptimistic completes the story: urgent
input stays urgent, heavy projections can lag, and mutations feel instant while the server
remains the source of truth.