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:
useOptimisticwith 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
messagesprop - 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
serverStatefrom the prop/parent - If the action fails, the optimistic state automatically reverts to the original
serverState useOptimisticonly works inside a transition (form action orstartTransition)
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
setOptimisticoutside a form action orstartTransitionhas no effect. Fix: Ensure you call it inside an async form action or wrap withstartTransition. -
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
useStatewith 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
revalidatePathorrevalidateTag) for the optimistic state to resolve correctly.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
useState + try/catch | You need manual rollback control | You want automatic rollback |
SWR optimisticData | You use SWR for data fetching | You use Server Components and actions |
TanStack Query onMutate | You use TanStack Query with optimistic updates | You use the App Router pattern |
| No optimistic UI | The action is fast (under 200ms) or failure is likely | Users perceive lag and the action usually succeeds |
FAQs
What does useOptimistic return and how do you use it?
- It returns
[optimisticState, setOptimistic] optimisticStatereflects the temporary UI state while an action is pending- Call
setOptimistic(value)inside a form action orstartTransitionto 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
serverStateprop - 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?
useOptimisticonly produces temporary state during a pending transition- Calling
setOptimisticoutside a form action orstartTransitionhas no effect - Form actions automatically create transitions, so wrapping in
startTransitionis 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
pendingflag 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
revalidatePathorrevalidateTagin 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
useStatewith try/catch instead
How does formRef.current?.reset() provide instant feedback?
- Calling
reset()immediately aftersetOptimisticclears 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
Related
- Server Action Forms — server actions and useActionState
- Form Patterns Basic — standard form patterns
- Form Error Display — displaying errors from failed actions
- Form Accessibility — announcing state changes