React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

react-19optimistic-uiformsactionshooks

useOptimistic Hook

Show an optimistic (predicted) state while an async action is in progress, then reconcile when it completes.

Recipe

Quick-reference recipe card — copy-paste ready.

const [optimisticMessages, addOptimistic] = useOptimistic(
  messages,
  (currentState, newMessage: string) => [
    ...currentState,
    { text: newMessage, sending: true },
  ]
);
 
// Call inside an action or transition
startTransition(() => {
  addOptimistic("Hello!");
  await sendMessage("Hello!");
});

When to reach for this: You want the UI to update instantly when a user performs an action (like sending a message, liking a post, or adding an item) while the server processes the request in the background.

Working Example

"use client";
 
import { useOptimistic, useActionState, useRef } from "react";
 
interface Message {
  id: number;
  text: string;
  sending?: boolean;
}
 
async function sendMessageAction(
  prevState: Message[],
  formData: FormData
): Promise<Message[]> {
  const text = formData.get("message") as string;
 
  // Simulate server delay
  await new Promise((resolve) => setTimeout(resolve, 1500));
 
  return [
    ...prevState,
    { id: Date.now(), text, sending: false },
  ];
}
 
export function Chat() {
  const [messages, formAction, isPending] = useActionState(sendMessageAction, [
    { id: 1, text: "Welcome to the chat!", sending: false },
  ]);
 
  const [optimisticMessages, addOptimistic] = useOptimistic(
    messages,
    (state, newMessage: string) => [
      ...state,
      { id: Date.now(), text: newMessage, sending: true },
    ]
  );
 
  const formRef = useRef<HTMLFormElement>(null);
 
  async function handleSubmit(formData: FormData) {
    const text = formData.get("message") as string;
    if (!text.trim()) return;
    formRef.current?.reset();
    addOptimistic(text);
    await formAction(formData);
  }
 
  return (
    <div className="space-y-3 max-w-sm">
      <ul className="space-y-2">
        {optimisticMessages.map((msg) => (
          <li
            key={msg.id}
            className={`text-sm px-3 py-2 rounded ${
              msg.sending
                ? "bg-gray-100 text-gray-400 italic"
                : "bg-blue-50 text-gray-900"
            }`}
          >
            {msg.text}
            {msg.sending && <span className="ml-2 text-xs">(sending...)</span>}
          </li>
        ))}
      </ul>
 
      <form ref={formRef} action={handleSubmit} className="flex gap-2">
        <input
          name="message"
          className="flex-1 border rounded px-3 py-2"
          placeholder="Type a message..."
          required
        />
        <button
          type="submit"
          disabled={isPending}
          className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
        >
          Send
        </button>
      </form>
    </div>
  );
}

What this demonstrates:

  • Messages appear instantly with a "sending" visual state
  • Once the server action completes, the optimistic state is replaced by the real state
  • If the action fails, the optimistic state automatically rolls back
  • The form resets immediately for a snappy feel, not after the server responds

Deep Dive

How It Works

  • useOptimistic takes the actual state and an update function that describes how to apply an optimistic change
  • When you call addOptimistic(value), React immediately shows the merged optimistic state
  • The optimistic state is temporary — it overlays the actual state only while an async action (transition) is pending
  • When the action completes and the actual state updates, the optimistic overlay is removed and the real state takes over
  • If the action fails, the optimistic state is automatically discarded, reverting to the actual state

Parameters & Return Values

ParameterTypeDescription
stateTThe actual (source-of-truth) state value
updateFn(currentState: T, optimisticValue: V) => TPure function that merges the optimistic value into the current state
ReturnTypeDescription
optimisticStateTCurrent state with optimistic updates applied (equals state when no action is pending)
addOptimistic(value: V) => voidFunction to trigger an optimistic update

Variations

Optimistic like button:

const [optimisticLikes, addLike] = useOptimistic(
  likes,
  (current, _: null) => current + 1
);
 
async function handleLike() {
  startTransition(async () => {
    addLike(null);
    await likePost(postId);
  });
}
 
return (
  <button onClick={handleLike}>
    {optimisticLikes} Likes
  </button>
);

Optimistic todo toggle:

const [optimisticTodos, toggleOptimistic] = useOptimistic(
  todos,
  (state, toggledId: number) =>
    state.map((todo) =>
      todo.id === toggledId ? { ...todo, done: !todo.done } : todo
    )
);

Optimistic delete:

const [optimisticItems, removeOptimistic] = useOptimistic(
  items,
  (state, removedId: string) => state.filter((item) => item.id !== removedId)
);

TypeScript Notes

// The generic types are inferred from parameters
const [optimistic, add] = useOptimistic(
  messages,                    // T = Message[]
  (state, text: string) => ... // V = string
);
// add: (value: string) => void
// optimistic: Message[]
 
// Explicit generics when needed
const [optimistic, add] = useOptimistic<Todo[], number>(
  todos,
  (state, toggledId) => state.map(t =>
    t.id === toggledId ? { ...t, done: !t.done } : t
  )
);

Gotchas

  • Calling addOptimistic outside a transition — The optimistic state only works correctly inside an async action or startTransition. Outside, the overlay is removed immediately. Fix: Always call addOptimistic inside startTransition or a form action.

  • Mutating state in updateFn — Mutating the current state array (e.g., state.push(item)) causes bugs. Fix: Return a new array or object from updateFn.

  • No error callback — There is no built-in way to show an error toast when the optimistic state rolls back. Fix: Handle errors in your action function and update state with an error message.

  • Multiple rapid optimistic updates — Each call to addOptimistic applies on top of the previous optimistic state, which can lead to unexpected results if the update function is not composable. Fix: Design your updateFn to be idempotent or additive.

  • Stale actual state — If the actual state prop changes from a different source while an action is pending, the optimistic overlay recalculates on top of the new actual state. Fix: This is usually the correct behavior, but be aware of it when debugging.

Alternatives

AlternativeUse WhenDon't Use When
useState with manual rollbackYou need custom rollback logic or error handlingSimple optimistic patterns where auto-rollback suffices
useTransition with isPendingYou only need a loading indicator, not an optimistic valueYou want the UI to reflect the expected result immediately
TanStack Query onMutateYou use TanStack Query and need optimistic updates with cache invalidationYou're using server actions and want a lightweight solution
Disable and spinnerSimplicity is preferred and latency is lowUsers expect instant feedback

Why useOptimistic over manual state? useOptimistic automatically rolls back on failure and merges correctly with the actual state when the action completes. Manual implementations are error-prone and verbose.

FAQs

What happens to the optimistic state if the async action fails?
  • The optimistic state is automatically discarded when the action completes (success or failure).
  • The UI reverts to the actual state, effectively "rolling back" the optimistic update.
  • There is no built-in error callback -- handle errors in your action function.
Why must I call addOptimistic inside a transition or form action?
  • The optimistic overlay only persists while an async action (transition) is pending.
  • If called outside a transition, the overlay is removed immediately on the next render.
  • Always use startTransition or a form action to keep the optimistic state visible.
How does useOptimistic differ from managing optimistic state manually with useState?
  • useOptimistic automatically rolls back on failure and merges correctly when the actual state updates.
  • Manual implementations require you to track the original state, handle rollback, and merge results yourself.
  • useOptimistic is less error-prone for common patterns like adding, toggling, or deleting items.
Gotcha: What happens if I mutate the state array inside the updateFn?
  • Mutating the array (e.g., state.push(item)) causes bugs because React expects immutable updates.
  • Always return a new array or object from updateFn.
// Wrong
(state, newItem) => { state.push(newItem); return state; }
 
// Correct
(state, newItem) => [...state, newItem]
Can I call addOptimistic multiple times before the action completes?
  • Yes. Each call applies on top of the previous optimistic state.
  • Make sure your updateFn is composable -- each call should produce a valid result when layered.
  • All optimistic overlays are discarded when the action completes.
How do I show an error toast when an optimistic update rolls back?
  • There is no built-in callback for rollback detection.
  • Handle errors in your action function and update the actual state with an error message.
  • Compare the actual state before and after the action to detect a rollback in the UI.
How do I type useOptimistic with TypeScript?
const [optimistic, add] = useOptimistic(
  messages,                         // T = Message[]
  (state, text: string) => [        // V = string
    ...state,
    { id: Date.now(), text, sending: true },
  ]
);
// add: (value: string) => void
// optimistic: Message[]
Gotcha: What happens if the actual state changes from a different source while an action is pending?
  • The optimistic overlay recalculates on top of the new actual state.
  • This is usually the correct behavior -- the overlay adapts to the latest reality.
  • Be aware of this when debugging unexpected optimistic values.
Can I use useOptimistic for a simple like/unlike toggle?
const [optimisticLikes, addLike] = useOptimistic(
  likes,
  (current, _: null) => current + 1
);
 
async function handleLike() {
  startTransition(async () => {
    addLike(null);
    await likePost(postId);
  });
}
  • Yes. The optimistic value shows immediately; the real count updates when the server responds.
When should I use useOptimistic vs just showing a spinner with isPending?
  • Use useOptimistic when users expect instant feedback (messages, likes, toggles).
  • Use a spinner when the result is uncertain and showing a predicted state could be misleading.
  • For low-latency actions, a spinner is fine; for high-latency actions, optimistic UI feels much faster.
  • useActionState — manage form state that drives optimistic updates
  • useTransition — transitions power the pending state that optimistic updates rely on
  • use — React 19 primitive for consuming async values
  • useState — the underlying state that useOptimistic overlays