What is Optimistic UI?

Optimistic UI: Optimistic UI updates the interface immediately as if an action succeeded, then syncs with the server and rolls back if the request fails.

In an optimistic update, the interface assumes the server will say yes. When a user likes a post, the heart fills and the count goes up at once, and the request runs in the background. If it fails, the UI reverts and explains what happened. Because most requests succeed, users see instant feedback almost every time instead of a spinner. The trade is extra work to handle the rare failure honestly.

When to use it

  • Good fits: likes, votes, reactions, toggles, reordering, renaming, marking as read, moving a card on a kanban board. High success rate, easy to undo, low stakes.
  • Poor fits: payments, sending email, deleting accounts, or anything where the server computes the result the user needs to see, such as a price or a generated ID they will act on.
  • Rule of thumb: if a failure would surprise and harm the user after they had moved on, wait for the server.

How to build one in React

React 19's useOptimistic hook holds a temporary value while an async transition runs. When the transition ends, it falls back to the value passed in, which should by then reflect the server's answer:

tsx
"use client"
import { startTransition, useOptimistic } from "react"
import { toast } from "@/components/ui/toast"

export function LikeButton({ liked, count, save }: {
  liked: boolean
  count: number
  save: (liked: boolean) => Promise<void>
}) {
  const [state, setState] = useOptimistic({ liked, count })
  function onClick() {
    const next = !state.liked
    startTransition(async () => {
      setState({ liked: next, count: state.count + (next ? 1 : -1) })
      try {
        await save(next)
      } catch {
        toast.error("Could not save your like. Try again.")
      }
    })
  }
  return (
    <button type="button" aria-pressed={state.liked} onClick={onClick}>
      Like <span className="tabular-nums">{state.count}</span>
    </button>
  )
}

Note that save must cause the parent's liked and count props to update, for example a Server Action that calls revalidatePath. Otherwise the optimistic value reverts even on success. Libraries such as TanStack Query offer the same pattern with onMutate to apply the change and onError to roll it back. Keep the optimistic value shaped like the real data, so the component renders the same markup either way. If pending items should look different, such as a comment at reduced opacity until it is saved, make that a deliberate style rather than a side effect.

Common mistakes

  • Silent rollback. If the value snaps back without explanation, users think they misclicked. Show a toast with a retry.
  • Races. Fast repeated clicks can resolve out of order. Let the latest request win, or queue writes per item.
  • Duplicates on retry. Send an idempotency key with create requests so a retried request does not create two records.
  • Moving focus. Rolling back should not move keyboard focus or scroll position.
  • Stale derived data. A count shown in two places, like a sidebar badge and a list header, must update in both or the UI contradicts itself.

MiniDev controls such as VoteControl, ReactionBar and WishlistButton render the instant state change; your data layer decides when to commit or revert. They pair naturally with small micro-interactions that confirm the action.

Vote ControluiUpvote and downvote control with a score between two chevrons, aria-pressed states and click again to undo. Built in React for forums and feedback boards.npx shadcn@latest add ui.minidev.pro/r/vote-control.jsonReaction BaruiRow of emoji reaction pills with counts, where active reactions get an accent border and aria-pressed. Toggle callbacks suit comments and chat threads.npx shadcn@latest add ui.minidev.pro/r/reaction-bar.json