ReactJS
11 Jul 2026

React 19 useOptimistic: Roll Back UI After an API Error

Optimistic UI is usually introduced as a speed trick: change the button now, talk to the server later. That is the easy half. The real design question arrives when the request fails. If three pieces of state all claim to be the truth, rollback becomes a small archaeology project. React 19's useOptimistic works better when the confirmed server value stays boring and the optimistic value stays temporary.

The rollback rule

Pass the confirmed value into useOptimistic. Change the optimistic value inside an Action. Update the confirmed value only after the API succeeds. If the request fails, leave the confirmed value alone; when the Action ends, React renders that unchanged base value again.

Think of the two values this way

  • Confirmed state: The receipt from the server. Change it only when the server accepts the mutation.
  • Optimistic state: A temporary preview shown while the Action is pending.
  • Error state: Feedback for the user, not a second copy of the business value.

A complete favorite button with API error recovery

The component below flips immediately, disables repeated clicks while the request is pending, accepts the value returned by the API, and shows a local error if the server rejects the update. Replace the endpoint with your own route; keep the response contract explicit.
"use client";

import {
  startTransition,
  useOptimistic,
  useState,
  useTransition,
} from "react";

type FavoriteResponse = { favorite: boolean };

function isFavoriteResponse(value: unknown): value is FavoriteResponse {
  return (
    typeof value === "object" &&
    value !== null &&
    "favorite" in value &&
    typeof value.favorite === "boolean"
  );
}

async function saveFavorite(
  articleId: string,
  favorite: boolean,
): Promise<boolean> {
  const response = await fetch(
    "/api/articles/" + encodeURIComponent(articleId) + "/favorite",
    {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ favorite }),
    },
  );

  if (!response.ok) {
    throw new Error("The server rejected the favorite update.");
  }

  const data: unknown = await response.json();

  if (!isFavoriteResponse(data)) {
    throw new Error("The favorite response has an unexpected shape.");
  }

  return data.favorite;
}

type FavoriteButtonProps = {
  articleId: string;
  initialFavorite: boolean;
};

export function FavoriteButton({
  articleId,
  initialFavorite,
}: FavoriteButtonProps) {
  const [favorite, setFavorite] = useState(initialFavorite);
  const [error, setError] = useState<string | null>(null);
  const [isPending, runAction] = useTransition();
  const [optimisticFavorite, setOptimisticFavorite] =
    useOptimistic(favorite);

  function toggleFavorite() {
    const nextFavorite = !optimisticFavorite;
    setError(null);

    runAction(async () => {
      setOptimisticFavorite(nextFavorite);

      try {
        const savedFavorite = await saveFavorite(articleId, nextFavorite);

        startTransition(() => {
          setFavorite(savedFavorite);
        });
      } catch {
        startTransition(() => {
          setError("Could not save that change. Please try again.");
        });
      }
    });
  }

  return (
    <div>
      <button
        type="button"
        aria-pressed={optimisticFavorite}
        disabled={isPending}
        onClick={toggleFavorite}
      >
        {optimisticFavorite ? "Saved" : "Save article"}
        {isPending ? "..." : ""}
      </button>

      <p aria-live="polite">
        {error ?? (isPending ? "Saving your choice..." : "")}
      </p>
    </div>
  );
}

Why this rolls back without setFavorite(previousValue)

useOptimistic(favorite) receives the confirmed base value. During the Action, setOptimisticFavorite creates a temporary render. On success, the API response becomes the new confirmed value. On failure, setFavorite is never called, so the base value has not moved. Once the Action finishes, the optimistic layer disappears and React renders the base value again.That is the rollback. There is no extra inverse mutation to remember, and no assumption that the previous value is simply the opposite of the failed value.

The manual rollback pattern that becomes fragile

The familiar approach updates ordinary state before the request and tries to reverse it in catch. It looks fine for a single boolean, but it ages badly when props refresh, another request lands, or the server normalizes the result.
async function toggleFavorite() {
  const previousFavorite = favorite;
  const nextFavorite = !favorite;

  setFavorite(nextFavorite);

  try {
    await saveFavorite(articleId, nextFavorite);
  } catch {
    setFavorite(previousFavorite);
  }
}
This code turns the preview into the base state before the server has confirmed anything. The catch block then hopes its captured previousFavorite is still current. With useOptimistic, confirmed and speculative values have separate jobs instead of taking turns pretending to be the same state.

Catch the API error when the user can recover

If the user can retry from the same button, catch the request error and render a short message beside that control. The optimistic value still rolls back because the confirmed value did not change. Throwing is better for failures that make the whole screen unreliable and belong in an Error Boundary.

Useful local error feedback

  • Say what happened: The change was not saved.
  • Preserve the next action: Keep the button available for retry after the pending state ends.
  • Do not expose internals: Log diagnostic details separately and show a safe message in the UI.
  • Use an aria-live region: Let assistive technology announce the result without moving focus unexpectedly.

Do not guess the final server value

The example commits savedFavorite, not nextFavorite. That difference is small but important. The server may reject the mutation, apply a business rule, merge a concurrent change, or return a normalized result. Optimistic UI is a preview of an expected outcome, not permission to ignore the response.

Fix the Transition warning

React expects the optimistic setter to run inside an Action. Calling it directly from a click handler produces the development warningAn optimistic state update occurred outside a Transition or Action. Wrap the async work in startTransition or use the setter inside an Action prop such as a form's action.
startTransition(async () => {
  setOptimisticFavorite(nextFavorite);
  await saveFavorite(articleId, nextFavorite);
});
A warning that flashes and immediately reverts is not a rollback bug. It usually means no Transition is keeping the optimistic state alive while the async work runs.

Reducers are safer for optimistic lists

A boolean can use the setter form. Lists benefit from the reducer form because React can reapply pending optimistic operations when the confirmed list changes. That matters if a new server response arrives while a local addition is still pending.
type PendingComment = Comment & { pending?: boolean };

const [optimisticComments, addOptimisticComment] = useOptimistic(
  comments,
  (currentComments, newComment: Comment) => [
    ...currentComments,
    { ...newComment, pending: true },
  ],
);
Keep the reducer pure. The network request belongs in the surrounding Action; the reducer's only job is to calculate the temporary view.

When optimistic UI is a poor bargain

Not every delay should be disguised. A failed star or bookmark is easy to explain and retry. A payment, irreversible deletion, scarce inventory reservation, or permission change has a much higher cost when the preview lies. Use a pending state and wait for confirmation when the server's answer changes what the user is allowed to do next.

Good optimistic candidates

  • Favorites and bookmarks: Low-cost, reversible, and easy to retry.
  • Likes and lightweight reactions: The expected result is obvious and locally visible.
  • Draft labels and preference toggles: The user can safely continue if rollback is clear.

Wait for confirmation instead

  • Payments and transfers: A false success state damages trust.
  • Destructive or irreversible actions: Confirmation matters more than perceived speed.
  • Inventory and permissions: The server may reject the change for reasons the client cannot know.

Common useOptimistic rollback mistakes

  • Updating confirmed state before the API succeeds: That removes the stable value React needs for rollback.
  • Calling the optimistic setter outside an Action: Wrap the work in a Transition or use an Action prop.
  • Ignoring the API response: Commit the server-confirmed value instead of assuming the preview was accepted unchanged.
  • Allowing uncontrolled duplicate mutations: Disable the control or design request ordering and idempotency explicitly.
  • Hiding failure feedback: A silent rollback looks like a broken button rather than a rejected request.
  • Using optimistic UI for high-risk mutations: Prefer honest pending feedback when a false success state is costly.

How this connects to React 19 Actions

useOptimistic is the preview layer of React's Action model. For the release context, read theReact 19.2 feature and upgrade guide. For typed submit-time validation and field errors, use theuseActionState TypeScript form guide.
React-focused tutorials: components, hooks, patterns, and the modern React ecosystem.