Optimistic UI with useOptimistic
Update the UI instantly on a mutation and reconcile with the server result — using useOptimistic and useActionState together.
Optimistic UI makes an app feel instant: you show the expected result the moment the user acts, before the server confirms, then reconcile when the real answer arrives. React's useOptimistic hook, paired with Server Actions, makes this clean.
How useOptimistic works
You give it the current state and a reducer describing how an optimistic update transforms it. Inside a transition (which a Server Action triggers), you push an optimistic value; React shows it immediately, then automatically reverts to the real state once the action resolves and the server data flows back. If the action fails, the optimistic value simply disappears — no manual rollback.
The pairing
useOptimistic— the instant, temporary view.useActionState— the pending flag and the authoritative returned result.- The Server Action — does the real write and revalidation.
Where it shines
Likes, todos, comments, reorderable lists — anything where the success path is overwhelmingly common and a rare failure reverting is acceptable. Do not use it for irreversible or high-stakes operations (payments) where a false 'success' flash is worse than a spinner.
Example
'use client';
import { useOptimistic } from 'react';
import { addTodo } from './actions';
type Todo = { id: string; title: string; sending?: boolean };
export function TodoList({ todos }: { todos: Todo[] }) {
const [optimistic, addOptimistic] = useOptimistic(
todos,
(state, newTitle: string) => [
...state,
{ id: 'temp', title: newTitle, sending: true },
],
);
async function action(formData: FormData) {
const title = formData.get('title') as string;
addOptimistic(title); // shows instantly
await addTodo(title); // real write + revalidatePath
} // optimistic item replaced by real data
return (
<>
<ul>
{optimistic.map((t) => (
<li key={t.id} style={{ opacity: t.sending ? 0.5 : 1 }}>
{t.title}
</li>
))}
</ul>
<form action={action}>
<input name="title" />
<button>Add</button>
</form>
</>
);
}When to use it
- A like button increments its count immediately on click via useOptimistic, then corrects to the server count once the action resolves.
- A todo list adds a new item to the UI instantly while the Server Action persists it, without a loading spinner.
- A cart add button shows the updated item count optimistically so the user feels instant feedback on slow network connections.
More examples
useOptimistic for a like button
useOptimistic immediately applies the increment, reverting only if the Server Action throws.
'use client';
import { useOptimistic, useTransition } from 'react';
import { likePost } from '@/app/actions';
export default function LikeButton({ postId, likes }: { postId: string; likes: number }) {
const [optimisticLikes, addOptimistic] = useOptimistic(likes, (prev) => prev + 1);
const [, startTransition] = useTransition();
return (
<button
onClick={() => startTransition(async () => {
addOptimistic(null);
await likePost(postId);
})}
>
{optimisticLikes} ❤️
</button>
);
}Optimistic list insert
The optimistic item appears immediately with reduced opacity to signal it is pending server confirmation.
'use client';
import { useOptimistic } from 'react';
import { addTodo } from '@/app/actions';
type Todo = { id: string; text: string; pending?: boolean };
export default function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addOptimistic] = useOptimistic(
todos,
(prev, newTodo: Todo) => [...prev, newTodo]
);
async function handleAdd(formData: FormData) {
const text = formData.get('text') as string;
addOptimistic({ id: 'temp', text, pending: true });
await addTodo(text);
}
return (
<>
<ul>{optimisticTodos.map(t => <li key={t.id} style={{ opacity: t.pending ? 0.5 : 1 }}>{t.text}</li>)}</ul>
<form action={handleAdd}><input name="text" /><button>Add</button></form>
</>
);
}Combine with useActionState
Combining useActionState with useOptimistic gives both a pending flag and instant UI updates in one form.
'use client';
import { useActionState, useOptimistic } from 'react';
import { createComment } from '@/app/actions';
export default function CommentForm({ comments }: { comments: string[] }) {
const [optimistic, addOptimistic] = useOptimistic(comments, (p, v: string) => [...p, v]);
const [, action, pending] = useActionState(async (_prev: unknown, fd: FormData) => {
addOptimistic(fd.get('text') as string);
return createComment(fd);
}, null);
return (
<>
<ul>{optimistic.map((c, i) => <li key={i}>{c}</li>)}</ul>
<form action={action}>
<input name="text" />
<button disabled={pending}>Post</button>
</form>
</>
);
}
Discussion