Optimistic UI: the art of not making users wait
Updating the interface without waiting for the server using useOptimistic and Server Actions: how it works, what happens on failure, and when not to use it.
You press the button, nothing changes, and half a second later the heart turns red. In that half second the user has already wondered "did it register?" and pressed again. Optimistic UI closes that gap: the interface does not wait for the server, it shows the result up front.
The idea is simple
Most requests succeed. So showing a spinner every time, and making everyone wait because of a rare failure, is a bad trade. Show the result first, send the request in the background, and roll back only if it fails.
With useOptimistic
React's useOptimistic hook lets you do this without managing state by hand.
It takes the real value and returns a temporary one while a transition is in progress:
"use client";
import { startTransition, useOptimistic } from "react";
import { toggleLike } from "./actions";
type Props = { postId: string; liked: boolean; count: number };
export function LikeButton({ postId, liked, count }: Props) {
const [optimistic, setOptimistic] = useOptimistic(
{ liked, count },
(state, nextLiked: boolean) => ({
liked: nextLiked,
count: state.count + (nextLiked ? 1 : -1),
}),
);
function handleClick() {
startTransition(async () => {
setOptimistic(!optimistic.liked);
await toggleLike(postId);
});
}
return (
<button onClick={handleClick} aria-pressed={optimistic.liked}>
{optimistic.liked ? "♥" : "♡"} {optimistic.count}
</button>
);
}The server side is an ordinary Server Action:
"use server";
import { revalidatePath } from "next/cache";
export async function toggleLike(postId: string) {
await db.like.toggle(postId);
revalidatePath(`/blog/${postId}`);
}revalidatePath sends the new liked and count values back through props, the
transition ends, and the real value replaces the optimistic one. The user never
notices the swap.
What if it fails?
This is the nicest part: you do not write the rollback. If the action throws, the
transition ends and useOptimistic returns to the real props automatically. All that
is left for you is to tell the user what happened:
startTransition(async () => {
setOptimistic(!optimistic.liked);
try {
await toggleLike(postId);
} catch {
toast.error("Could not save, please try again");
}
});A silent rollback is the worst option. If the heart turns red and then white again on its own, the user assumes it is a bug.
When not to use it
Optimistic UI is a promise. Do not make it where breaking it is expensive:
- Payments and orders. Showing "Paid" and then taking it back destroys trust.
- Irreversible actions. Deleting, sending a message, publishing.
- Actions whose result depends on the server. If the response carries data you cannot know in advance (an ID, a computed price, validation), do not guess.
- Requests that are often rejected. When failure is likely, rollback becomes the norm.
Likes, bookmarks, checkboxes, reordering a list, adding a comment: these are ideal candidates.
Takeaway
Speed is not only about milliseconds, it is also about how things feel. Getting a 300 ms request down to 50 ms is hard, but making it feel like 0 ms takes one hook. Start with small, frequent actions where a failure is cheap: you will notice the difference on the first click.