useOptimistic Hook
Show an optimistic (predicted) state while an async action is in progress, then reconcile when it completes.
Recipe
Quick-reference recipe card — copy-paste ready.
const [optimisticMessages, addOptimistic] = useOptimistic(
messages,
(currentState, newMessage: string) => [
...currentState,
{ text: newMessage, sending: true },
]
);
// Call inside an action or transition
startTransition(() => {
addOptimistic("Hello!");
await sendMessage("Hello!");
});When to reach for this: You want the UI to update instantly when a user performs an action (like sending a message, liking a post, or adding an item) while the server processes the request in the background.
Working Example
"use client";
import { useOptimistic, useActionState, useRef } from "react";
interface Message {
id: number;
text: string;
sending?: boolean;
}
async function sendMessageAction(
prevState: Message[],
formData: FormData
): Promise<Message[]> {
const text = formData.get("message") as string;
// Simulate server delay
await new Promise((resolve) => setTimeout(resolve, 1500));
return [
...prevState,
{ id: Date.now(), text, sending: false },
];
}
export function Chat() {
const [messages, formAction, isPending] = useActionState(sendMessageAction, [
{ id: 1, text: "Welcome to the chat!", sending: false },
]);
const [optimisticMessages, addOptimistic] = useOptimistic(
messages,
(state, newMessage: string) => [
...state,
{ id: Date.now(), text: newMessage, sending: true },
]
);
const formRef = useRef<HTMLFormElement>(null);
async function handleSubmit(formData: FormData) {
const text = formData.get("message") as string;
if (!text.trim()) return;
formRef.current?.reset();
addOptimistic(text);
await formAction(formData);
}
return (
<div className="space-y-3 max-w-sm">
<ul className="space-y-2">
{optimisticMessages.map((msg) => (
<li
key={msg.id}
className={`text-sm px-3 py-2 rounded ${
msg.sending
? "bg-gray-100 text-gray-400 italic"
: "bg-blue-50 text-gray-900"
}`}
>
{msg.text}
{msg.sending && <span className="ml-2 text-xs">(sending...)</span>}
</li>
))}
</ul>
<form ref={formRef} action={handleSubmit} className="flex gap-2">
<input
name="message"
className="flex-1 border rounded px-3 py-2"
placeholder="Type a message..."
required
/>
<button
type="submit"
disabled={isPending}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
>
Send
</button>
</form>
</div>
);
}What this demonstrates:
- Messages appear instantly with a "sending" visual state
- Once the server action completes, the optimistic state is replaced by the real state
- If the action fails, the optimistic state automatically rolls back
- The form resets immediately for a snappy feel, not after the server responds
Deep Dive
How It Works
useOptimistictakes the actual state and an update function that describes how to apply an optimistic change- When you call
addOptimistic(value), React immediately shows the merged optimistic state - The optimistic state is temporary — it overlays the actual state only while an async action (transition) is pending
- When the action completes and the actual state updates, the optimistic overlay is removed and the real state takes over
- If the action fails, the optimistic state is automatically discarded, reverting to the actual state
Parameters & Return Values
| Parameter | Type | Description |
|---|---|---|
state | T | The actual (source-of-truth) state value |
updateFn | (currentState: T, optimisticValue: V) => T | Pure function that merges the optimistic value into the current state |
| Return | Type | Description |
|---|---|---|
optimisticState | T | Current state with optimistic updates applied (equals state when no action is pending) |
addOptimistic | (value: V) => void | Function to trigger an optimistic update |
Variations
Optimistic like button:
const [optimisticLikes, addLike] = useOptimistic(
likes,
(current, _: null) => current + 1
);
async function handleLike() {
startTransition(async () => {
addLike(null);
await likePost(postId);
});
}
return (
<button onClick={handleLike}>
{optimisticLikes} Likes
</button>
);Optimistic todo toggle:
const [optimisticTodos, toggleOptimistic] = useOptimistic(
todos,
(state, toggledId: number) =>
state.map((todo) =>
todo.id === toggledId ? { ...todo, done: !todo.done } : todo
)
);Optimistic delete:
const [optimisticItems, removeOptimistic] = useOptimistic(
items,
(state, removedId: string) => state.filter((item) => item.id !== removedId)
);TypeScript Notes
// The generic types are inferred from parameters
const [optimistic, add] = useOptimistic(
messages, // T = Message[]
(state, text: string) => ... // V = string
);
// add: (value: string) => void
// optimistic: Message[]
// Explicit generics when needed
const [optimistic, add] = useOptimistic<Todo[], number>(
todos,
(state, toggledId) => state.map(t =>
t.id === toggledId ? { ...t, done: !t.done } : t
)
);Gotchas
-
Calling addOptimistic outside a transition — The optimistic state only works correctly inside an async action or
startTransition. Outside, the overlay is removed immediately. Fix: Always calladdOptimisticinsidestartTransitionor a form action. -
Mutating state in updateFn — Mutating the current state array (e.g.,
state.push(item)) causes bugs. Fix: Return a new array or object fromupdateFn. -
No error callback — There is no built-in way to show an error toast when the optimistic state rolls back. Fix: Handle errors in your action function and update state with an error message.
-
Multiple rapid optimistic updates — Each call to
addOptimisticapplies on top of the previous optimistic state, which can lead to unexpected results if the update function is not composable. Fix: Design yourupdateFnto be idempotent or additive. -
Stale actual state — If the actual state prop changes from a different source while an action is pending, the optimistic overlay recalculates on top of the new actual state. Fix: This is usually the correct behavior, but be aware of it when debugging.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
useState with manual rollback | You need custom rollback logic or error handling | Simple optimistic patterns where auto-rollback suffices |
useTransition with isPending | You only need a loading indicator, not an optimistic value | You want the UI to reflect the expected result immediately |
TanStack Query onMutate | You use TanStack Query and need optimistic updates with cache invalidation | You're using server actions and want a lightweight solution |
| Disable and spinner | Simplicity is preferred and latency is low | Users expect instant feedback |
Why useOptimistic over manual state? useOptimistic automatically rolls back on failure and merges correctly with the actual state when the action completes. Manual implementations are error-prone and verbose.
FAQs
What happens to the optimistic state if the async action fails?
- The optimistic state is automatically discarded when the action completes (success or failure).
- The UI reverts to the actual state, effectively "rolling back" the optimistic update.
- There is no built-in error callback -- handle errors in your action function.
Why must I call addOptimistic inside a transition or form action?
- The optimistic overlay only persists while an async action (transition) is pending.
- If called outside a transition, the overlay is removed immediately on the next render.
- Always use
startTransitionor a form action to keep the optimistic state visible.
How does useOptimistic differ from managing optimistic state manually with useState?
useOptimisticautomatically rolls back on failure and merges correctly when the actual state updates.- Manual implementations require you to track the original state, handle rollback, and merge results yourself.
useOptimisticis less error-prone for common patterns like adding, toggling, or deleting items.
Gotcha: What happens if I mutate the state array inside the updateFn?
- Mutating the array (e.g.,
state.push(item)) causes bugs because React expects immutable updates. - Always return a new array or object from
updateFn.
// Wrong
(state, newItem) => { state.push(newItem); return state; }
// Correct
(state, newItem) => [...state, newItem]Can I call addOptimistic multiple times before the action completes?
- Yes. Each call applies on top of the previous optimistic state.
- Make sure your
updateFnis composable -- each call should produce a valid result when layered. - All optimistic overlays are discarded when the action completes.
How do I show an error toast when an optimistic update rolls back?
- There is no built-in callback for rollback detection.
- Handle errors in your action function and update the actual state with an error message.
- Compare the actual state before and after the action to detect a rollback in the UI.
How do I type useOptimistic with TypeScript?
const [optimistic, add] = useOptimistic(
messages, // T = Message[]
(state, text: string) => [ // V = string
...state,
{ id: Date.now(), text, sending: true },
]
);
// add: (value: string) => void
// optimistic: Message[]Gotcha: What happens if the actual state changes from a different source while an action is pending?
- The optimistic overlay recalculates on top of the new actual state.
- This is usually the correct behavior -- the overlay adapts to the latest reality.
- Be aware of this when debugging unexpected optimistic values.
Can I use useOptimistic for a simple like/unlike toggle?
const [optimisticLikes, addLike] = useOptimistic(
likes,
(current, _: null) => current + 1
);
async function handleLike() {
startTransition(async () => {
addLike(null);
await likePost(postId);
});
}- Yes. The optimistic value shows immediately; the real count updates when the server responds.
When should I use useOptimistic vs just showing a spinner with isPending?
- Use
useOptimisticwhen users expect instant feedback (messages, likes, toggles). - Use a spinner when the result is uncertain and showing a predicted state could be misleading.
- For low-latency actions, a spinner is fine; for high-latency actions, optimistic UI feels much faster.
Related
- useActionState — manage form state that drives optimistic updates
- useTransition — transitions power the pending state that optimistic updates rely on
- use — React 19 primitive for consuming async values
- useState — the underlying state that
useOptimisticoverlays