Optimistic UI in React: An Interface That Knowingly Lies
Optimistic UI in React: An Interface That Knowingly Lies
In the article on microinteractions, I wrote about the Doherty threshold — if a system responds in under 400ms, users perceive it as instantaneous. Microinteractions close that gap by animating a button's state before the server's response comes back. Optimistic UI goes a step further: it doesn't just animate the waiting, it assumes upfront that the action will succeed, and immediately shows the end result — the heart in a "Like" button fills in instantly, the counter goes up by one, even though the request to the server has only just started. The interface tells the user something that isn't true yet — and bets that it will be in a moment.
This works flawlessly as long as the assumption is correct — and in practice, for actions like "like," "add to favorites," or "mark as read," it's correct the overwhelming majority of the time. The problem starts where most implementations try to do this by hand: setState on click, catch with a reverse setState on error. It looks harmless in an isolated test with one click. It falls apart when a user clicks the button twice, quickly, in a row — exactly the same fourth element of Saffer's model (loops and modes) from the microinteractions article, except this time the bug isn't cosmetic, it leads to a genuinely inconsistent state.
Why a manual rollback breaks down with two clicks
Picture the most obvious, hand-rolled implementation: a click immediately flips liked in local state, sends the request, and on catch reverts liked back to the value it had before the click, saved in a variable before the request went out.
jsx
// Naive rollback – works for one click, breaks with twoconst handleClick = async () => {const previousLiked = liked; // frozen snapshot of state BEFORE the clicksetLiked(!liked);try {await toggleLike(postId, !liked);} catch {setLiked(previousLiked); // rolls back to the state before THIS click}};
Here's the scenario that breaks it: the user clicks "like" (request A starts, previousLiked = false), then 100ms later clicks "unlike" (request B starts, previousLiked = true, because local state has already flipped to true). If request A fails while request B is still in flight or has already succeeded, the catch for request A rolls the state back to false — overwriting the effect of click B, which the user made deliberately and which may have already succeeded. One request's rollback wiped out the other's result, because both were operating on the same shared state variable, with no awareness of each other. This is exactly the kind of bug that never shows up in a quick manual test — it shows up in production, when a real user clicks faster than the developer imagined while writing the code.
How useOptimistic avoids this structurally, not with a patch
useOptimistic in React isn't a more convenient wrapper around setState plus try/catch. It's a different conceptual model: instead of holding one mutable state field that you manually flip and manually revert, useOptimistic computes a temporary overlay on the fly, based on the current, real state, visible only for the duration of that particular operation (transition). When the operation finishes — whether it succeeds or fails — the overlay disappears on its own, revealing whatever the real state currently is. You don't write rollback code. Rollback is a natural consequence of the temporary overlay disappearing, not a separate code path you can forget to handle, or handle wrong.
jsx
"use client";import { useOptimistic, useTransition } from "react";import { toggleLikeAction } from "./actions";const LikeButton = ({ postId, liked, likeCount }) => {const [isPending, startTransition] = useTransition();const [optimistic, setOptimistic] = useOptimistic({ liked, likeCount },(state, nextLiked) => ({liked: nextLiked,likeCount: state.likeCount + (nextLiked ? 1 : -1),}),);const handleClick = () => {const nextLiked = !optimistic.liked;startTransition(async () => {setOptimistic(nextLiked);await toggleLikeAction(postId, nextLiked);// No manual rollback in a catch – if the action throws,// React discards the overlay once the transition ends anyway,// revealing the real "liked" value passed in via props.});};return (<buttontype="button"onClick={handleClick}aria-pressed={optimistic.liked}className={isPending ? "like-button--pending" : ""}><span aria-hidden="true">{optimistic.liked ? "♥" : "♡"}</span>{optimistic.likeCount}</button>);};export default LikeButton;
The key difference from the manual version: the overlay computed by useOptimistic is based on the state passed as the first argument — the current, real state from props (usually coming from a Server Action and revalidatePath, as in the Server Actions article) — rather than a separate, frozen snapshot manually stored in a local variable. When the user clicks twice in a row, each call to setOptimistic computes a new overlay relative to the current state, so the second click can't accidentally get overwritten by a delayed rollback from the first — there's no separate "revert to the saved value" path that could get confused about which moment in time it's supposed to be reverting to.
When optimistic UI is a good idea, and when it's a dangerous lie
Not every action fits this pattern, and it's not a matter of taste — it's a matter of the consequences of a wrong assumption. "Like," "add to favorites," "mark as read" — cheap in their consequences, easily reversible, almost always succeed. A payment, an irreversible account deletion, sending a message to another person — here, optimistically showing success before it's actually happened is risky: the user makes further decisions based on information that might turn out to be false, and undoing that kind of "lie" a few seconds later is far more disorienting than an ordinary spinner that simply takes a bit longer.
Even where optimistic UI does make sense, a silent rollback with no feedback at all recreates the exact perceptual problem it was supposed to solve. The user saw the filled-in heart, considered the matter closed, moved their attention elsewhere — and a moment later the heart quietly reverts to empty, with no explanation. It's the same "missing loops and modes" mistake from Saffer's model, just shifted in time: there was feedback for success, but none for failure. The fix is the same tool described in the accessibility article — aria-live="polite" or role="alert" on the failure message, so the state reverting isn't silent, visually or audibly, for anyone who isn't staring directly at that button at the exact moment it reverts.
Pitfalls and best practices
setOptimisticmust be called inside a transition. OutsidestartTransition(or an action passed touseActionState/<form action>),useOptimisticwon't behave as expected — the mechanism is inseparably tied to a specific transition's lifecycle, not to an arbitrary moment during render.- The updater function passed to
useOptimisticmust be pure and cheap. It runs synchronously during render to compute the overlay — complex computation or side effects in there is a category error, not just a performance concern. - Don't hide failure silently. A silent rollback with no message is worse than not having an optimistic update at all — the user gets false confirmation, and then no information at all that something actually went wrong.
- Reserve this pattern for cheap, reversible actions. Where the cost of a wrong assumption is high (payments, irreversible operations), a plain loading state with a clear after-the-fact confirmation is the better choice, not an interface that guesses upfront.
Conclusion
Optimistic UI isn't an animation trick — it's a deliberate assumption that an action will succeed, shown to the user before the server has confirmed it. A manual implementation with setState and catch works right up until someone clicks faster than the developer anticipated — then one operation's rollback can overwrite the other's result, because both share the same frozen state snapshot. useOptimistic removes this class of bug structurally: the overlay is always computed against the current, real state rather than a separately stored value, so rollback isn't code you write and can write wrong — it's the natural consequence of a temporary assumption disappearing. What's left is one decision no API can make for you: whether a given action is cheap and reversible enough that lying about it, even briefly, is worth doing at all.