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?)
  • value is the committed state—the data you trust when nothing is in flight.
  • reducer maps (current, action) => nextOptimisticState. For simple cases, the reducer can append, toggle flags, or reorder items.
  • optimisticState is what you render right now, including any pending drafts.
  • addOptimistic enqueues a draft on top of value until value updates 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:

  1. 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).
  2. Partial merge — Server returns field-level corrections; parent updates todos with merged data so the next value reflects what actually happened. You still avoid manual optimistic clearing—the new props become the base.
  3. 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.