React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

react-19useOptimisticoptimistic-updatesformsux

useOptimistic - Show instant UI feedback while async actions complete

Recipe

"use client";
 
import { useOptimistic } from "react";
 
type Message = { id: string; text: string; sending?: boolean };
 
function Chat({
  messages,
  sendMessage,
}: {
  messages: Message[];
  sendMessage: (text: string) => Promise<void>;
}) {
  const [optimisticMessages, addOptimistic] = useOptimistic(
    messages,
    (state, newText: string) => [
      ...state,
      { id: "temp-" + Date.now(), text: newText, sending: true },
    ]
  );
 
  async function handleSubmit(formData: FormData) {
    const text = formData.get("text") as string;
    addOptimistic(text);
    await sendMessage(text);
  }
 
  return (
    <div>
      <ul>
        {optimisticMessages.map((msg) => (
          <li key={msg.id} className={msg.sending ? "opacity-50" : ""}>
            {msg.text}
            {msg.sending && " (sending...)"}
          </li>
        ))}
      </ul>
      <form action={handleSubmit}>
        <input name="text" required />
        <button type="submit">Send</button>
      </form>
    </div>
  );
}

When to reach for this: Use useOptimistic whenever you want the UI to update instantly while an async operation (server action, API call) is in flight -- likes, messages, toggles, cart updates, any mutation where the user should not wait.

Working Example

// A todo list with optimistic add, toggle, and delete
"use client";
 
import { useOptimistic, useActionState, useRef } from "react";
 
type Todo = {
  id: string;
  text: string;
  completed: boolean;
  pending?: boolean;
  deleting?: boolean;
};
 
// Simulate server actions
async function serverAddTodo(text: string): Promise<Todo> {
  await new Promise((r) => setTimeout(r, 1000));
  return { id: crypto.randomUUID(), text, completed: false };
}
 
async function serverToggleTodo(id: string): Promise<void> {
  await new Promise((r) => setTimeout(r, 500));
}
 
async function serverDeleteTodo(id: string): Promise<void> {
  await new Promise((r) => setTimeout(r, 500));
}
 
type OptimisticAction =
  | { type: "add"; text: string }
  | { type: "toggle"; id: string }
  | { type: "delete"; id: string };
 
export default function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
  const [todos, setTodos] = useActionState(
    async (_prev: Todo[], formData: FormData) => {
      const text = formData.get("text") as string;
      addOptimistic({ type: "add", text });
      const newTodo = await serverAddTodo(text);
      return [..._prev, newTodo];
    },
    initialTodos
  );
 
  const [optimisticTodos, addOptimistic] = useOptimistic(
    todos,
    (state: Todo[], action: OptimisticAction) => {
      switch (action.type) {
        case "add":
          return [...state, { id: "temp", text: action.text, completed: false, pending: true }];
        case "toggle":
          return state.map((t) =>
            t.id === action.id ? { ...t, completed: !t.completed, pending: true } : t
          );
        case "delete":
          return state.map((t) =>
            t.id === action.id ? { ...t, deleting: true } : t
          );
      }
    }
  );
 
  const formRef = useRef<HTMLFormElement>(null);
 
  async function handleToggle(id: string) {
    addOptimistic({ type: "toggle", id });
    await serverToggleTodo(id);
  }
 
  async function handleDelete(id: string) {
    addOptimistic({ type: "delete", id });
    await serverDeleteTodo(id);
  }
 
  return (
    <div className="max-w-md mx-auto">
      <h1 className="text-2xl font-bold mb-4">Todos</h1>
 
      <ul className="space-y-2">
        {optimisticTodos
          .filter((t) => !t.deleting)
          .map((todo) => (
            <li
              key={todo.id}
              className={`flex items-center gap-2 ${todo.pending ? "opacity-50" : ""}`}
            >
              <input
                type="checkbox"
                checked={todo.completed}
                onChange={() => handleToggle(todo.id)}
              />
              <span className={todo.completed ? "line-through" : ""}>{todo.text}</span>
              <button onClick={() => handleDelete(todo.id)} className="ml-auto text-red-500">
                Delete
              </button>
            </li>
          ))}
      </ul>
 
      <form ref={formRef} action={async (formData) => {
        const text = formData.get("text") as string;
        addOptimistic({ type: "add", text });
        formRef.current?.reset();
        const newTodo = await serverAddTodo(text);
        // In a real app, revalidation would update the todos
      }}>
        <div className="flex gap-2 mt-4">
          <input name="text" required className="border p-2 rounded flex-1" />
          <button type="submit" className="bg-blue-500 text-white px-4 rounded">Add</button>
        </div>
      </form>
    </div>
  );
}

What this demonstrates:

  • A single useOptimistic call handling three different action types (add, toggle, delete)
  • Optimistic items styled differently (opacity, strikethrough) to indicate pending state
  • Deleted items hidden immediately via the deleting flag
  • The optimistic state automatically reverts to the real todos value when the action completes

Deep Dive

How It Works

  • useOptimistic(passthrough, updateFn) returns [optimisticState, addOptimistic].
    • passthrough is the real data source (e.g., from props or useActionState). When no action is in flight, optimisticState === passthrough.
    • updateFn(currentState, optimisticValue) is a pure function that produces the optimistic version of the state.
    • addOptimistic(value) triggers the updateFn immediately, making the UI update before the async work finishes.
  • When the async action (form action, transition, server action) resolves, React replaces the optimistic state with the updated passthrough value. There is no manual "commit" or "rollback" step.
  • If the action fails, the optimistic state reverts to the original passthrough value. The user sees the change "undo" itself.
  • useOptimistic is designed to work with React's transition and action system. Calling addOptimistic outside of an action or transition has no effect.
  • Multiple addOptimistic calls during the same action are batched. The updateFn receives the accumulated optimistic state.

Variations

Simple boolean toggle:

function LikeButton({ isLiked, onToggle }: { isLiked: boolean; onToggle: () => Promise<void> }) {
  const [optimisticLiked, setOptimisticLiked] = useOptimistic(isLiked);
 
  return (
    <form action={async () => {
      setOptimisticLiked(!optimisticLiked);
      await onToggle();
    }}>
      <button type="submit">{optimisticLiked ? "Unlike" : "Like"}</button>
    </form>
  );
}

With useActionState for combined form state and optimistic UI:

"use client";
 
import { useActionState, useOptimistic } from "react";
import { addToCart } from "./actions";
 
function CartButton({ count }: { count: number }) {
  const [serverCount, action, isPending] = useActionState(addToCart, count);
  const [optimisticCount, setOptimisticCount] = useOptimistic(serverCount);
 
  return (
    <form action={async (formData) => {
      setOptimisticCount((c) => c + 1);
      await action(formData);
    }}>
      <button type="submit">Add to Cart ({optimisticCount})</button>
    </form>
  );
}

TypeScript Notes

  • useOptimistic<State, Action>(passthrough: State, updateFn: (state: State, action: Action) => State) returns [State, (action: Action) => void].
  • When no updateFn is provided, the second argument to addOptimistic replaces the state directly: useOptimistic<State>(passthrough: State) returns [State, (newState: State) => void].
  • The Action type parameter controls what you pass to addOptimistic. Use a discriminated union for multiple action types.

Gotchas

  • Calling addOptimistic outside an action or transition -- The optimistic update will not apply or will revert immediately. Fix: Always call addOptimistic inside a form action, server action, or startTransition callback.
  • Mutating state in updateFn -- The updateFn must be pure. Mutating the current state array/object causes bugs. Fix: Always return a new array/object: [...state, newItem].
  • Optimistic state does not persist after action completes -- The passthrough value replaces optimistic state when the action finishes. If the server returns different data than expected, the UI will "jump." Fix: Ensure your optimistic prediction matches what the server will return.
  • Error rollback is automatic but silent -- When an action fails, the optimistic state reverts without notification. Fix: Combine with useActionState error handling to show an error message.
  • Multiple rapid submissions -- Each action queues; optimistic updates stack on top of each other. This is correct behavior but can look odd if updates conflict. Fix: Design your updateFn to handle accumulated state correctly.

Alternatives

ApproachWhen to choose
useOptimisticReact 19 built-in, works with form actions and transitions
TanStack Query useMutation with onMutateNeed caching, retry, and sophisticated rollback
SWR mutate with optimisticDataAlready using SWR for data fetching
Manual useState toggleSimple cases where you manage pending state yourself
Redux Toolkit optimistic updatesLarge Redux app with existing middleware

FAQs

What does useOptimistic return and how does it work?
  • Returns [optimisticState, addOptimistic]
  • optimisticState equals the passthrough value when no action is in flight
  • Calling addOptimistic(value) triggers the updateFn immediately to produce an optimistic version of the state
  • When the async action resolves, React replaces optimistic state with the updated passthrough value
What happens if the async action fails after an optimistic update?
  • The optimistic state automatically reverts to the original passthrough value
  • There is no manual "commit" or "rollback" step needed
  • Combine with useActionState error handling to show an error message, since rollback is silent
Can you use useOptimistic without a form action?
  • addOptimistic must be called inside a form action, server action, or startTransition callback
  • Calling it outside of an action or transition has no effect or reverts immediately
  • Wrap your async logic in startTransition if not using a form
How do you handle multiple action types (add, toggle, delete) with a single useOptimistic?

Use a discriminated union for the action type:

type Action =
  | { type: "add"; text: string }
  | { type: "toggle"; id: string }
  | { type: "delete"; id: string };
 
const [optimistic, dispatch] = useOptimistic(
  todos,
  (state, action: Action) => {
    switch (action.type) {
      case "add": return [...state, { id: "temp", text: action.text }];
      case "toggle": return state.map(t => t.id === action.id ? { ...t, completed: !t.completed } : t);
      case "delete": return state.filter(t => t.id !== action.id);
    }
  }
);
How do you use useOptimistic for a simple boolean toggle like a Like button?
function LikeButton({ isLiked, onToggle }) {
  const [optimisticLiked, setOptimisticLiked] = useOptimistic(isLiked);
  return (
    <form action={async () => {
      setOptimisticLiked(!optimisticLiked);
      await onToggle();
    }}>
      <button type="submit">{optimisticLiked ? "Unlike" : "Like"}</button>
    </form>
  );
}

When no updateFn is provided, addOptimistic replaces the state directly.

How do you visually distinguish optimistic items from confirmed items?
  • Add a flag like pending: true or sending: true in the updateFn return value
  • Style optimistic items differently (e.g., opacity-50, italic text, "(sending...)" label)
  • The flag disappears when the real data replaces the optimistic state
Can multiple addOptimistic calls stack during the same action?
  • Yes. Multiple calls during the same action are batched
  • The updateFn receives the accumulated optimistic state from prior calls
  • Design your updateFn to handle accumulated state correctly to avoid conflicts
Gotcha: Why does the UI "jump" when the server returns data after an optimistic update?
  • The passthrough value replaces optimistic state when the action finishes
  • If the server returns different data than your optimistic prediction, the UI will show a visible change
  • Ensure your optimistic prediction closely matches what the server will return
Gotcha: Can you mutate the state array in the updateFn?
  • No. The updateFn must be pure -- mutating the current state causes bugs
  • Always return a new array or object: [...state, newItem] instead of state.push(newItem)
  • This follows the same immutability rules as React state updates
How do you type useOptimistic with a custom action type in TypeScript?
useOptimistic<State, Action>(
  passthrough: State,
  updateFn: (state: State, action: Action) => State
): [State, (action: Action) => void]

Use a discriminated union for the Action type to support multiple action types.

What is the TypeScript signature when useOptimistic is used without an updateFn?
  • useOptimistic<State>(passthrough: State) returns [State, (newState: State) => void]
  • The second argument to addOptimistic replaces the state directly
  • This is the simplest form for single-value toggles or counters
  • Form Actions -- The primary way to trigger optimistic updates
  • Server Actions -- Server functions that pair with optimistic UI
  • use() Hook -- Reading async data that feeds into optimistic state
  • Overview -- Full list of React 19 features