Optimistic Forms
Use useOptimistic with forms to show instant feedback while server actions process - with pending UI and automatic rollback on failure.
Search across all documentation pages
Use useOptimistic with forms to show instant feedback while server actions process - with pending UI and automatic rollback on failure.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
// 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 actionsmessages propuseOptimistic(serverState, updaterFn) returns [optimisticState, setOptimistic]setOptimistic(value) is called, React applies the updater function to produce a temporary stateserverState from the prop/parentserverStateuseOptimistic only works inside a transition (form action or startTransition)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;
}
);// 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[]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.
| 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 |
[optimisticState, setOptimistic]optimisticState reflects the temporary UI state while an action is pendingsetOptimistic(value) inside a form action or startTransition to apply the optimistic updateserverState propuseOptimistic only produces temporary state during a pending transitionsetOptimistic outside a form action or startTransition has no effectstartTransition is only needed outside formsconst message = { ...data, id: `temp-${Date.now()}`, pending: true };
addOptimistic({ type: "add", message });
// In the UI:
{msg.pending && <span className="text-blue-500">Sending...</span>}pending flag to optimistic items and style them differently (opacity, label)(currentState, actionPayload) and returns the new optimistic state{ type: "add"; message: Message } | { type: "delete"; id: string }revalidatePath or revalidateTag in the server action to trigger a re-renderconst [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;
});(state: T, action: A) => T - the return type matches the state typeuseState with try/catch insteadreset() immediately after setOptimistic clears the input fieldReviewed by Chris St. John·Last updated Jul 10, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥