The classic optimistic-update bug: show the change immediately, then forget to roll back when the server says no. useOptimistic handles the rollback for you — the synthetic state automatically disappears when the action settles.
The signature
const [optimisticState, addOptimistic] = useOptimistic(realState, updater) where updater is (current, optimisticValue) => newState. The hook returns:
optimisticState — the state to render. Equals realState when no action is pending; equals the updater's output while an action is in flight.
addOptimistic — call inside an action to apply the optimistic update.
The pattern
Inside your action, call addOptimistic(value) as the first thing, then await the real work. The UI immediately reflects the optimistic value. When the action settles (success or failure), useOptimistic discards the optimistic value and re-syncs to whatever realState is at that point.
The 'no lying' principle
Optimistic updates are a UX accelerant, not a correctness shortcut. If the action might genuinely fail (network down, validation error, permission denied), the rollback flash IS the honesty — the user sees the immediate hope, then sees the truth. Better than no feedback, far better than silently dropping their action.
Common pairing
useOptimistic + useActionState: realState comes from useActionState; useOptimistic wraps it for the synthetic version. Most production patterns end up using both.
Optimistic for the user, authoritative for the system. The optimistic state is what the user sees and feels; the real state is what survives reload. Keep them aligned in the success path; let the visible flash on failure tell the truth.
Code
useOptimistic + useActionState — a like button·tsx
import { useActionState, useOptimistic } from "react";
type LikeState = { count: number };
async function toggleLike(state: LikeState, formData: FormData): Promise<LikeState> {
const delta = formData.get("delta") === "plus" ? 1 : -1;
await fetch("/api/like", {
method: "POST",
body: JSON.stringify({ delta }),
});
return { count: state.count + delta };
}
export function LikeButton({ initial }: { initial: number }) {
const [state, formAction] = useActionState(toggleLike, { count: initial });
// Optimistic wrapper — mirrors state until an action is in flight.
const [optimistic, addOptimistic] = useOptimistic(
state,
(current: LikeState, delta: number) => ({ count: current.count + delta })
);
async function handleClick(delta: 1 | -1) {
addOptimistic(delta); // UI updates instantly to count ± 1
const fd = new FormData();
fd.set("delta", delta === 1 ? "plus" : "minus");
await formAction(fd); // real action runs, real state lands when done
}
return (
<div className="flex items-center gap-2">
<button onClick={() => handleClick(-1)}>-</button>
<span className="font-mono">{optimistic.count}</span>
<button onClick={() => handleClick(1)}>+</button>
</div>
);
}
Optimistic chat message append·tsx
import { useOptimistic } from "react";
type Message = { id: string; text: string };
export function ChatList({ messages, sendMessage }: {
messages: Message[];
sendMessage: (text: string) => Promise<void>;
}) {
const [optimisticMessages, addOptimistic] = useOptimistic(
messages,
(current: Message[], pendingText: string): Message[] => [
...current,
{ id: `pending-${Date.now()}`, text: pendingText },
]
);
async function handleSend(formData: FormData) {
const text = formData.get("text") as string;
addOptimistic(text); // appears instantly in optimisticMessages
await sendMessage(text);
// When messages updates (parent re-renders with the real message),
// useOptimistic syncs back to the real list automatically.
}
return (
<>
<ul>
{optimisticMessages.map((m) => (
<li key={m.id} className={m.id.startsWith("pending-") ? "opacity-60" : ""}>
{m.text}
</li>
))}
</ul>
<form action={handleSend}>
<input name="text" required />
<button type="submit">Send</button>
</form>
</>
);
}
Build a todo list with optimistic add. Real state lives in a parent (useState array). The add action POSTs to a fake endpoint that randomly fails 30% of the time. Use useOptimistic so the new item appears immediately, then either persists (on success) or disappears (on failure — show a toast). Verify that during the in-flight request the user can already see the item; after a failure, they see it vanish along with a 'Try again' notice.
Hint
For the random failure: if (Math.random() < 0.3) throw new Error('flaky network');. The optimistic item should be styled differently (opacity-60) so the user knows it's not yet confirmed.
Progress
Progress is local-only — sign in to sync across devices.