Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
"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.
// 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:
useOptimistic call handling three different action types (add, toggle, delete)deleting flagtodos value when the action completesuseOptimistic(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.passthrough value. There is no manual "commit" or "rollback" step.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.addOptimistic calls during the same action are batched. The updateFn receives the accumulated optimistic state.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>
);
}useOptimistic<State, Action>(passthrough: State, updateFn: (state: State, action: Action) => State) returns [State, (action: Action) => void].updateFn is provided, the second argument to addOptimistic replaces the state directly: useOptimistic<State>(passthrough: State) returns [State, (newState: State) => void].Action type parameter controls what you pass to addOptimistic. Use a discriminated union for multiple action types.addOptimistic inside a form action, server action, or startTransition callback.updateFn must be pure. Mutating the current state array/object causes bugs. Fix: Always return a new array/object: [...state, newItem].useActionState error handling to show an error message.updateFn to handle accumulated state correctly.| Approach | When to choose |
|---|---|
useOptimistic | React 19 built-in, works with form actions and transitions |
TanStack Query useMutation with onMutate | Need caching, retry, and sophisticated rollback |
SWR mutate with optimisticData | Already using SWR for data fetching |
Manual useState toggle | Simple cases where you manage pending state yourself |
| Redux Toolkit optimistic updates | Large Redux app with existing middleware |
[optimisticState, addOptimistic]optimisticState equals the passthrough value when no action is in flightaddOptimistic(value) triggers the updateFn immediately to produce an optimistic version of the statepassthrough valuepassthrough valueuseActionState error handling to show an error message, since rollback is silentaddOptimistic must be called inside a form action, server action, or startTransition callbackstartTransition if not using a formUse 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);
}
}
);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.
pending: true or sending: true in the updateFn return valueopacity-50, italic text, "(sending...)" label)updateFn receives the accumulated optimistic state from prior callsupdateFn to handle accumulated state correctly to avoid conflictspassthrough value replaces optimistic state when the action finishesupdateFn must be pure -- mutating the current state causes bugs[...state, newItem] instead of state.push(newItem)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.
useOptimistic<State>(passthrough: State) returns [State, (newState: State) => void]addOptimistic replaces the state directlyReviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥