React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

useOptimisticformspending-uirollbackoptimistic-update

Optimistic Forms

Use useOptimistic with forms to show instant feedback while server actions process — with pending UI and automatic rollback on failure.

Recipe

Quick-reference recipe card — copy-paste ready.

"use client";
 
import { useOptimistic, useActionState } from "react";
 
type Todo = { id: string; text: string; completed: boolean };
 
function TodoList({
  todos,
  toggleAction,
}: {
  todos: Todo[];
  toggleAction: (formData: FormData) => Promise<void>;
}) {
  const [optimisticTodos, setOptimistic] = useOptimistic(
    todos,
    (state, toggledId: string) =>
      state.map((t) => (t.id === toggledId ? { ...t, completed: !t.completed } : t))
  );
 
  return (
    <ul>
      {optimisticTodos.map((todo) => (
        <li key={todo.id}>
          <form
            action={async (formData) => {
              setOptimistic(todo.id);
              await toggleAction(formData);
            }}
          >
            <input type="hidden" name="id" value={todo.id} />
            <button type="submit" className={todo.completed ? "line-through opacity-50" : ""}>
              {todo.text}
            </button>
          </form>
        </li>
      ))}
    </ul>
  );
}

When to reach for this: When the server action is likely to succeed and you want the UI to feel instant — toggling, liking, deleting, or reordering items.

Working Example

// app/actions/messages.ts
"use server";
 
import { revalidatePath } from "next/cache";
 
export type Message = {
  id: string;
  text: string;
  author: string;
  createdAt: string;
  pending?: boolean;
};
 
export async function addMessage(prevState: any, formData: FormData) {
  const text = formData.get("text") as string;
  if (!text?.trim()) return { error: "Message cannot be empty" };
 
  // Simulate network delay
  await new Promise((r) => setTimeout(r, 1500));
 
  // Simulate occasional failure
  if (Math.random() < 0.2) {
    return { error: "Failed to send. Try again." };
  }
 
  await db.message.create({
    data: { text, author: "You", createdAt: new Date().toISOString() },
  });
 
  revalidatePath("/chat");
  return { success: true };
}
 
export async function deleteMessage(formData: FormData) {
  const id = formData.get("id") as string;
  await db.message.delete({ where: { id } });
  revalidatePath("/chat");
}
// app/chat/page.tsx
"use client";
 
import { useOptimistic, useActionState, useRef } from "react";
import { addMessage, deleteMessage, type Message } from "@/app/actions/messages";
 
export function ChatRoom({ messages }: { messages: Message[] }) {
  const formRef = useRef<HTMLFormElement>(null);
 
  const [optimisticMessages, addOptimistic] = useOptimistic(
    messages,
    (state, action: { type: "add"; message: Message } | { type: "delete"; id: string }) => {
      if (action.type === "add") return [...state, action.message];
      if (action.type === "delete") return state.filter((m) => m.id !== action.id);
      return state;
    }
  );
 
  const [sendState, sendAction, isSending] = useActionState(
    async (prev: any, formData: FormData) => {
      const text = formData.get("text") as string;
      addOptimistic({
        type: "add",
        message: {
          id: `temp-${Date.now()}`,
          text,
          author: "You",
          createdAt: new Date().toISOString(),
          pending: true,
        },
      });
      formRef.current?.reset();
      return addMessage(prev, formData);
    },
    null
  );
 
  return (
    <div className="mx-auto max-w-lg">
      <div className="space-y-3 rounded border p-4" style={{ minHeight: 300 }}>
        {optimisticMessages.map((msg) => (
          <div
            key={msg.id}
            className={`flex items-start justify-between rounded p-2 ${
              msg.pending ? "bg-blue-50 opacity-60" : "bg-gray-50"
            }`}
          >
            <div>
              <span className="text-xs font-medium text-gray-500">{msg.author}</span>
              <p className="text-sm">{msg.text}</p>
              {msg.pending && <span className="text-xs text-blue-500">Sending...</span>}
            </div>
            {!msg.pending && (
              <form
                action={async (formData) => {
                  addOptimistic({ type: "delete", id: msg.id });
                  await deleteMessage(formData);
                }}
              >
                <input type="hidden" name="id" value={msg.id} />
                <button type="submit" className="text-xs text-red-400 hover:text-red-600">
                  Delete
                </button>
              </form>
            )}
          </div>
        ))}
      </div>
 
      {sendState?.error && (
        <p className="mt-2 text-sm text-red-600">{sendState.error}</p>
      )}
 
      <form ref={formRef} action={sendAction} className="mt-3 flex gap-2">
        <input
          name="text"
          placeholder="Type a message..."
          className="flex-1 rounded border p-2"
          required
        />
        <button
          type="submit"
          disabled={isSending}
          className="rounded bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
        >
          Send
        </button>
      </form>
    </div>
  );
}

What this demonstrates:

  • useOptimistic with a reducer-style updater for add and delete actions
  • Pending visual state (opacity, "Sending..." label)
  • Automatic rollback when the server action fails — React reverts to the real messages prop
  • Form reset immediately after optimistic update
  • Error display for failed actions

Deep Dive

How It Works

  • useOptimistic(serverState, updaterFn) returns [optimisticState, setOptimistic]
  • When setOptimistic(value) is called, React applies the updater function to produce a temporary state
  • The optimistic state is shown while the async action (form action or transition) is pending
  • When the action completes, React replaces the optimistic state with the new serverState from the prop/parent
  • If the action fails, the optimistic state automatically reverts to the original serverState
  • useOptimistic only works inside a transition (form action or startTransition)

Variations

Like button with optimistic count:

function LikeButton({ postId, likes, isLiked }: { postId: string; likes: number; isLiked: boolean }) {
  const [optimistic, setOptimistic] = useOptimistic(
    { likes, isLiked },
    (state, _: void) => ({
      likes: state.isLiked ? state.likes - 1 : state.likes + 1,
      isLiked: !state.isLiked,
    })
  );
 
  return (
    <form action={async () => {
      setOptimistic(undefined);
      await toggleLike(postId);
    }}>
      <button type="submit">
        {optimistic.isLiked ? "heart-filled" : "heart"} {optimistic.likes}
      </button>
    </form>
  );
}

Optimistic reorder:

const [optimisticItems, reorder] = useOptimistic(
  items,
  (state, { from, to }: { from: number; to: number }) => {
    const next = [...state];
    const [moved] = next.splice(from, 1);
    next.splice(to, 0, moved);
    return next;
  }
);

TypeScript Notes

// useOptimistic is generic
const [state, setState] = useOptimistic<Message[], { type: "add"; message: Message }>(
  messages,
  (state, action) => {
    // action is typed as { type: "add"; message: Message }
    return [...state, action.message];
  }
);
 
// The updater function must return the same type as the first argument
// (state: Message[], action: Action) => Message[]

Gotchas

  • Only works in transitions — Calling setOptimistic outside a form action or startTransition has no effect. Fix: Ensure you call it inside an async form action or wrap with startTransition.

  • Rollback replaces entire state — When the action fails, the entire optimistic state reverts, not just the failed item. This is correct behavior but can surprise you if multiple actions are in flight.

  • No manual rollback API — You cannot manually revert optimistic state. Fix: The automatic rollback handles failures. For manual control, use regular useState with try/catch.

  • Stale closure in updater — The updater receives the current optimistic state (including previous optimistic updates), so sequential calls compose correctly. But avoid closing over external state.

  • Server component re-render required — After the action completes, the parent Server Component must re-render with new data (via revalidatePath or revalidateTag) for the optimistic state to resolve correctly.

Alternatives

AlternativeUse WhenDon't Use When
useState + try/catchYou need manual rollback controlYou want automatic rollback
SWR optimisticDataYou use SWR for data fetchingYou use Server Components and actions
TanStack Query onMutateYou use TanStack Query with optimistic updatesYou use the App Router pattern
No optimistic UIThe action is fast (under 200ms) or failure is likelyUsers perceive lag and the action usually succeeds

FAQs

What does useOptimistic return and how do you use it?
  • It returns [optimisticState, setOptimistic]
  • optimisticState reflects the temporary UI state while an action is pending
  • Call setOptimistic(value) inside a form action or startTransition to apply the optimistic update
How does automatic rollback work when a server action fails?
  • When the action completes (success or failure), React replaces optimistic state with the real serverState prop
  • If the action fails, the optimistic changes are discarded and the UI reverts automatically
  • You do not need to write any rollback logic yourself
Why must setOptimistic be called inside a transition?
  • useOptimistic only produces temporary state during a pending transition
  • Calling setOptimistic outside a form action or startTransition has no effect
  • Form actions automatically create transitions, so wrapping in startTransition is only needed outside forms
How do you show a "pending" visual state for optimistic items?
const message = { ...data, id: `temp-${Date.now()}`, pending: true };
addOptimistic({ type: "add", message });
 
// In the UI:
{msg.pending && <span className="text-blue-500">Sending...</span>}
  • Add a pending flag to optimistic items and style them differently (opacity, label)
How does the reducer-style updater function work in useOptimistic?
  • The updater receives (currentState, actionPayload) and returns the new optimistic state
  • Use a discriminated union for action types: { type: "add"; message: Message } | { type: "delete"; id: string }
  • Sequential calls compose correctly because each receives the latest optimistic state
Gotcha: What happens when multiple optimistic actions are in flight and one fails?
  • Rollback replaces the entire optimistic state, not just the failed item
  • All pending optimistic changes revert when React resolves to the real server state
  • This is expected behavior but can surprise you if multiple items are being modified
Gotcha: Why does useOptimistic require the parent Server Component to re-render?
  • After the action completes, the real data must come from the server via a new prop value
  • Call revalidatePath or revalidateTag in the server action to trigger a re-render
  • Without revalidation, the optimistic state resolves to stale data
How do you type the useOptimistic hook with TypeScript generics?
const [state, setState] = useOptimistic<
  Message[],
  { type: "add"; message: Message } | { type: "delete"; id: string }
>(messages, (state, action) => {
  // action is fully typed
  if (action.type === "add") return [...state, action.message];
  if (action.type === "delete") return state.filter(m => m.id !== action.id);
  return state;
});
Why does the updater function must return the same type as the first argument?
  • TypeScript enforces that (state: T, action: A) => T — the return type matches the state type
  • This prevents accidentally returning a different shape
  • The generic parameter ensures type consistency between server state and optimistic state
When should you skip optimistic UI and use regular loading states instead?
  • Skip optimistic UI when the action is fast (under 200ms) or failure is likely
  • Use it when the action usually succeeds and users perceive lag (e.g., toggling, liking, deleting)
  • For manual rollback control, use useState with try/catch instead
How does formRef.current?.reset() provide instant feedback?
  • Calling reset() immediately after setOptimistic clears the input field
  • The user sees their message appear in the list and the input cleared instantly
  • If the action fails, the optimistic message disappears via automatic rollback