useOptimistic Makes Your UI Lie (In a Good Way)
useOptimistic Makes Your UI Lie (In a Good Way)#
You know that thing where you tap a heart button and nothing happens for 400ms? Your thumb hovers. Did it register? You tap again. Now you've un-liked the thing you just liked. Or worse, you've double-liked it and the backend throws a 409.
I've watched this happen on user research calls at least a dozen times over the years, and every time I think the same thing: the UI should've lied. It should've shown the heart filled immediately and dealt with the server response whenever it arrived. The user doesn't care about your round-trip time. They care that their intent was acknowledged.
This is optimistic UI. And before React 19, building it meant managing two parallel state tracks yourself -- the "what we told the user" state and the "what the server actually confirmed" state -- plus writing rollback logic for when the server disagreed. I've done this enough times to know it's not hard, exactly, but it's the kind of thing where you forget the rollback path one time and ship a bug that makes like counts drift.
useOptimistic just... does it. The API is almost suspiciously simple:
const [optimisticValue, addOptimistic] = useOptimistic(
actualValue,
(currentOptimistic, update) => newOptimisticState
);
You give it the real value (server-confirmed state), a reducer that says "given the current optimistic state and a pending update, what should we show?", and it gives you back the value to render plus a function to trigger updates. When the enclosing transition completes -- success or failure -- React reverts to the real value automatically.
That automatic revert is the whole point. You never write catch (err) { setLikeCount(prev => prev - 1) } again.
Here's what it looks like for the heart button scenario:
"use client";
import { useOptimistic, useTransition } from "react";
import { togglePostLikeAction } from "@/modules/feed/infrastructure/actions/toggle-post-like-action";
interface PostLikeButtonProps {
postId: string;
initialLikeCount: number;
initialIsLiked: boolean;
}
export function PostLikeButton({
postId,
initialLikeCount,
initialIsLiked,
}: PostLikeButtonProps) {
const [isPending, startTransition] = useTransition();
const [optimisticState, addOptimistic] = useOptimistic(
{ likeCount: initialLikeCount, isLiked: initialIsLiked },
(current, action: "like" | "unlike") => ({
likeCount: action === "like" ? current.likeCount + 1 : current.likeCount - 1,
isLiked: action === "like",
})
);
function handleToggle() {
const nextAction = optimisticState.isLiked ? "unlike" : "like";
startTransition(async () => {
addOptimistic(nextAction);
await togglePostLikeAction({ postId });
});
}
return (
<button
onClick={handleToggle}
disabled={isPending}
aria-label={optimisticState.isLiked ? "Unlike post" : "Like post"}
className={optimisticState.isLiked ? "text-rose-500" : "text-gray-400"}
>
<HeartIcon filled={optimisticState.isLiked} />
<span>{optimisticState.likeCount}</span>
</button>
);
}
No separate pendingLike state. No onError handler to decrement the count. If togglePostLikeAction throws, optimisticState snaps back to whatever initialLikeCount and initialIsLiked were. Done.
The one thing that tripped me up: addOptimistic only works inside a transition. Call it outside startTransition or a form action and you get a dev-mode warning. This is intentional -- React needs the transition boundary to know when to revert. But if you're used to just calling state setters wherever, it catches you off guard.
For something more involved, here's a comment submission. This is trickier because you're adding to a list, the item doesn't have a real ID yet, and you want to visually distinguish it from confirmed comments:
"use client";
import { useOptimistic, useActionState } from "react";
import { addCommentAction } from "@/modules/feed/infrastructure/actions/add-comment-action";
import type { Comment } from "@/modules/feed/domain";
interface CommentSectionProps {
postId: string;
initialComments: Comment[];
currentUser: { id: string; displayName: string; avatarUrl: string };
}
export function CommentSection({
postId,
initialComments,
currentUser,
}: CommentSectionProps) {
const [optimisticComments, addOptimisticComment] = useOptimistic(
initialComments,
(current, newComment: Comment) => [...current, newComment]
);
const [state, formAction, isPending] = useActionState(
async (_prev: { error: string | null }, formData: FormData) => {
const body = formData.get("body") as string;
addOptimisticComment({
id: `optimistic-${Date.now()}`,
body,
authorId: currentUser.id,
authorName: currentUser.displayName,
authorAvatarUrl: currentUser.avatarUrl,
createdAt: new Date().toISOString(),
isPending: true,
});
const result = await addCommentAction({ postId, body });
return result;
},
{ error: null }
);
return (
<div className="space-y-4">
<ul className="space-y-3">
{optimisticComments.map((comment) => (
<li
key={comment.id}
className={comment.isPending ? "opacity-60" : "opacity-100"}
>
<CommentCard comment={comment} />
{comment.isPending && (
<span className="text-xs text-gray-400 ml-10">Posting...</span>
)}
</li>
))}
</ul>
<form action={formAction} className="flex gap-2">
<textarea
name="body"
placeholder="Add a comment..."
className="flex-1 resize-none rounded border px-3 py-2 text-sm"
rows={2}
/>
<button
type="submit"
disabled={isPending}
className="self-end rounded bg-indigo-600 px-4 py-2 text-sm text-white disabled:opacity-50"
>
{isPending ? "Posting..." : "Post"}
</button>
</form>
{state.error && (
<p className="text-sm text-red-600">{state.error}</p>
)}
</div>
);
}
The comment shows up instantly at 60% opacity with a "Posting..." label. When the server action resolves, the parent Server Component re-renders with the real comment list (after a revalidatePath in the action), the optimistic item disappears, and the confirmed comment takes its place. Seamless.
Compare this with how I used to do it:
// the old way -- it works but there's more surface area for bugs
const [comments, setComments] = useState(initialComments);
const [pendingComment, setPendingComment] = useState<PendingComment | null>(null);
async function handleSubmit(body: string) {
const optimistic = { id: "temp", body, isPending: true };
setPendingComment(optimistic);
try {
const saved = await addComment({ postId, body });
setComments((prev) => [...prev, saved]);
} catch (err) {
// forgot this once and shipped a phantom comment bug
toast.error("Failed to post comment");
} finally {
setPendingComment(null);
}
}
const displayComments = pendingComment
? [...comments, pendingComment]
: comments;
That finally block, the derived displayComments array, the extra state variable -- none of it is complicated individually, but I've shipped at least two bugs from forgetting one of these pieces over the years. useOptimistic makes the rollback path impossible to forget because it's built in.
One thing to be careful about: that optimistic ID (optimistic-${Date.now()}) is fake. If any child component tries to use it for a follow-up request -- replying to the comment, reporting it, whatever -- it'll fail. Disable secondary actions on optimistic items. I learned this on a Saturday afternoon when a test user tried to edit a comment that didn't exist on the server yet.
And the revert handles the happy path beautifully, but the user still needs to know when something goes wrong. The UI snapping back to the previous state is not enough feedback on its own. Always pair useOptimistic with an error display from useActionState or a toast. The revert fixes the data; it doesn't fix the user's confusion.
Where I'd use this: like/bookmark toggles, item reordering, comment submission, anything where the user expects instant acknowledgment and the server will almost certainly agree. Where I wouldn't: payments, destructive deletes, anything where briefly showing the wrong state could cause real confusion. Pablo asked me "would you optimistically show a bank transfer as completed?" and the answer is obviously no. But a heart button? Lie to me. I'll take the faster UI.