useOptimistic - Show instant UI feedback while async actions complete
Recipe
"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.
Working Example
// 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:
- A single
useOptimisticcall handling three different action types (add, toggle, delete) - Optimistic items styled differently (opacity, strikethrough) to indicate pending state
- Deleted items hidden immediately via the
deletingflag - The optimistic state automatically reverts to the real
todosvalue when the action completes
Deep Dive
How It Works
useOptimistic(passthrough, updateFn)returns[optimisticState, addOptimistic].passthroughis the real data source (e.g., from props oruseActionState). 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 theupdateFnimmediately, making the UI update before the async work finishes.
- When the async action (form action, transition, server action) resolves, React replaces the optimistic state with the updated
passthroughvalue. There is no manual "commit" or "rollback" step. - If the action fails, the optimistic state reverts to the original
passthroughvalue. The user sees the change "undo" itself. useOptimisticis designed to work with React's transition and action system. CallingaddOptimisticoutside of an action or transition has no effect.- Multiple
addOptimisticcalls during the same action are batched. TheupdateFnreceives the accumulated optimistic state.
Variations
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>
);
}TypeScript Notes
useOptimistic<State, Action>(passthrough: State, updateFn: (state: State, action: Action) => State)returns[State, (action: Action) => void].- When no
updateFnis provided, the second argument toaddOptimisticreplaces the state directly:useOptimistic<State>(passthrough: State)returns[State, (newState: State) => void]. - The
Actiontype parameter controls what you pass toaddOptimistic. Use a discriminated union for multiple action types.
Gotchas
- Calling addOptimistic outside an action or transition -- The optimistic update will not apply or will revert immediately. Fix: Always call
addOptimisticinside a form action, server action, orstartTransitioncallback. - Mutating state in updateFn -- The
updateFnmust be pure. Mutating the current state array/object causes bugs. Fix: Always return a new array/object:[...state, newItem]. - Optimistic state does not persist after action completes -- The passthrough value replaces optimistic state when the action finishes. If the server returns different data than expected, the UI will "jump." Fix: Ensure your optimistic prediction matches what the server will return.
- Error rollback is automatic but silent -- When an action fails, the optimistic state reverts without notification. Fix: Combine with
useActionStateerror handling to show an error message. - Multiple rapid submissions -- Each action queues; optimistic updates stack on top of each other. This is correct behavior but can look odd if updates conflict. Fix: Design your
updateFnto handle accumulated state correctly.
Alternatives
| 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 |
FAQs
What does useOptimistic return and how does it work?
- Returns
[optimisticState, addOptimistic] optimisticStateequals thepassthroughvalue when no action is in flight- Calling
addOptimistic(value)triggers theupdateFnimmediately to produce an optimistic version of the state - When the async action resolves, React replaces optimistic state with the updated
passthroughvalue
What happens if the async action fails after an optimistic update?
- The optimistic state automatically reverts to the original
passthroughvalue - There is no manual "commit" or "rollback" step needed
- Combine with
useActionStateerror handling to show an error message, since rollback is silent
Can you use useOptimistic without a form action?
addOptimisticmust be called inside a form action, server action, orstartTransitioncallback- Calling it outside of an action or transition has no effect or reverts immediately
- Wrap your async logic in
startTransitionif not using a form
How do you handle multiple action types (add, toggle, delete) with a single useOptimistic?
Use 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);
}
}
);How do you use useOptimistic for a simple boolean toggle like a Like button?
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.
How do you visually distinguish optimistic items from confirmed items?
- Add a flag like
pending: trueorsending: truein theupdateFnreturn value - Style optimistic items differently (e.g.,
opacity-50, italic text, "(sending...)" label) - The flag disappears when the real data replaces the optimistic state
Can multiple addOptimistic calls stack during the same action?
- Yes. Multiple calls during the same action are batched
- The
updateFnreceives the accumulated optimistic state from prior calls - Design your
updateFnto handle accumulated state correctly to avoid conflicts
Gotcha: Why does the UI "jump" when the server returns data after an optimistic update?
- The
passthroughvalue replaces optimistic state when the action finishes - If the server returns different data than your optimistic prediction, the UI will show a visible change
- Ensure your optimistic prediction closely matches what the server will return
Gotcha: Can you mutate the state array in the updateFn?
- No. The
updateFnmust be pure -- mutating the current state causes bugs - Always return a new array or object:
[...state, newItem]instead ofstate.push(newItem) - This follows the same immutability rules as React state updates
How do you type useOptimistic with a custom action type in TypeScript?
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.
What is the TypeScript signature when useOptimistic is used without an updateFn?
useOptimistic<State>(passthrough: State)returns[State, (newState: State) => void]- The second argument to
addOptimisticreplaces the state directly - This is the simplest form for single-value toggles or counters
Related
- Form Actions -- The primary way to trigger optimistic updates
- Server Actions -- Server functions that pair with optimistic UI
- use() Hook -- Reading async data that feeds into optimistic state
- Overview -- Full list of React 19 features