C.W.K.
Stream
Lesson 04 of 06 · published

useOptimistic — Instant UI Without Lying

~14 min · useoptimistic, react-19, actions

Level 0React Novice
0 XP0/54 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
The classic optimistic-update bug: show the change immediately, then forget to roll back when the server says no. useOptimistic handles the rollback for you — the synthetic state automatically disappears when the action settles.

The signature

const [optimisticState, addOptimistic] = useOptimistic(realState, updater) where updater is (current, optimisticValue) => newState. The hook returns:

  • optimisticState — the state to render. Equals realState when no action is pending; equals the updater's output while an action is in flight.
  • addOptimistic — call inside an action to apply the optimistic update.

The pattern

Inside your action, call addOptimistic(value) as the first thing, then await the real work. The UI immediately reflects the optimistic value. When the action settles (success or failure), useOptimistic discards the optimistic value and re-syncs to whatever realState is at that point.

The 'no lying' principle

Optimistic updates are a UX accelerant, not a correctness shortcut. If the action might genuinely fail (network down, validation error, permission denied), the rollback flash IS the honesty — the user sees the immediate hope, then sees the truth. Better than no feedback, far better than silently dropping their action.

Common pairing

useOptimistic + useActionState: realState comes from useActionState; useOptimistic wraps it for the synthetic version. Most production patterns end up using both.

Optimistic for the user, authoritative for the system. The optimistic state is what the user sees and feels; the real state is what survives reload. Keep them aligned in the success path; let the visible flash on failure tell the truth.

Code

useOptimistic + useActionState — a like button·tsx
import { useActionState, useOptimistic } from "react";

type LikeState = { count: number };

async function toggleLike(state: LikeState, formData: FormData): Promise<LikeState> {
  const delta = formData.get("delta") === "plus" ? 1 : -1;
  await fetch("/api/like", {
    method: "POST",
    body: JSON.stringify({ delta }),
  });
  return { count: state.count + delta };
}

export function LikeButton({ initial }: { initial: number }) {
  const [state, formAction] = useActionState(toggleLike, { count: initial });

  // Optimistic wrapper — mirrors state until an action is in flight.
  const [optimistic, addOptimistic] = useOptimistic(
    state,
    (current: LikeState, delta: number) => ({ count: current.count + delta })
  );

  async function handleClick(delta: 1 | -1) {
    addOptimistic(delta); // UI updates instantly to count ± 1
    const fd = new FormData();
    fd.set("delta", delta === 1 ? "plus" : "minus");
    await formAction(fd); // real action runs, real state lands when done
  }

  return (
    <div className="flex items-center gap-2">
      <button onClick={() => handleClick(-1)}>-</button>
      <span className="font-mono">{optimistic.count}</span>
      <button onClick={() => handleClick(1)}>+</button>
    </div>
  );
}
Optimistic chat message append·tsx
import { useOptimistic } from "react";

type Message = { id: string; text: string };

export function ChatList({ messages, sendMessage }: {
  messages: Message[];
  sendMessage: (text: string) => Promise<void>;
}) {
  const [optimisticMessages, addOptimistic] = useOptimistic(
    messages,
    (current: Message[], pendingText: string): Message[] => [
      ...current,
      { id: `pending-${Date.now()}`, text: pendingText },
    ]
  );

  async function handleSend(formData: FormData) {
    const text = formData.get("text") as string;
    addOptimistic(text); // appears instantly in optimisticMessages
    await sendMessage(text);
    // When messages updates (parent re-renders with the real message),
    // useOptimistic syncs back to the real list automatically.
  }

  return (
    <>
      <ul>
        {optimisticMessages.map((m) => (
          <li key={m.id} className={m.id.startsWith("pending-") ? "opacity-60" : ""}>
            {m.text}
          </li>
        ))}
      </ul>
      <form action={handleSend}>
        <input name="text" required />
        <button type="submit">Send</button>
      </form>
    </>
  );
}

External links

Exercise

Build a todo list with optimistic add. Real state lives in a parent (useState array). The add action POSTs to a fake endpoint that randomly fails 30% of the time. Use useOptimistic so the new item appears immediately, then either persists (on success) or disappears (on failure — show a toast). Verify that during the in-flight request the user can already see the item; after a failure, they see it vanish along with a 'Try again' notice.
Hint
For the random failure: if (Math.random() < 0.3) throw new Error('flaky network');. The optimistic item should be styled differently (opacity-60) so the user knows it's not yet confirmed.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.