What useOptimistic Actually Saves You

Published: (May 26, 2026 at 01:20 PM EDT)
3 min read
Source: Dev.to

Source: Dev.to

The problem with optimistic UI

A checkbox toggle should feel instant. When the toggle needs to persist to a server you have two choices:

  1. Wait for the response – the UI feels sluggish.
  2. Update immediately – optimistic UI, which feels faster for users but requires extra code to handle failures.

The manual optimistic‑UI implementation quickly accumulates state variables, flags, and try/catch blocks, especially as the component grows more complex.

Manual optimistic implementation

function TodoItem({ todo, onToggle }) {
  const [checked, setChecked] = useState(todo.completed);
  const [pending, setPending] = useState(false);

  async function handleToggle() {
    setChecked((prev) => !prev);
    setPending(true);

    try {
      await onToggle(todo.id, !checked);
    } catch {
      setChecked(checked); // rollback on error
    } finally {
      setPending(false);
    }
  }

  return (
    
      
      {todo.title}
    
  );
}
  • Lines: 26
  • Edge cases:
    • Component unmounts while the request is in flight.
    • Parent updates todo.completed from another source while the toggle is pending, causing the local checked state to drift from the prop.
    • Each edge case adds more state and branching logic.

Using useOptimistic with startTransition

function TodoItem({ todo, onToggle }) {
  const [optimisticChecked, addOptimistic] = useOptimistic(
    todo.completed,
    (state, next) => next,
  );

  function handleToggle() {
    startTransition(async () => {
      const next = !todo.completed;
      addOptimistic(next);          // optimistic update
      await onToggle(todo.id, next);
    });
  }

  return (
    
      
      {todo.title}
    
  );
}
  • Lines: 23 – only a few fewer, but the real savings are mental, not textual.

Why it saves you

  • startTransition wraps the async work in a React Transition. When the transition finishes (whether it succeeds or fails), React re‑renders the component.
  • useOptimistic simply mirrors todo.completed.
    • If the parent updates the prop (success), the checkbox stays checked.
    • If the parent does not update the prop (failure), the checkbox automatically rolls back to the previous value.
  • No separate “pending” flag, no extra try/catch for state reset – the prop remains the single source of truth.

Error handling still matters

useOptimistic takes care of the UI state, but you still need to:

  • Show an error toast or notification.
  • Log the failure.
  • Offer a retry mechanism.

These concerns live outside the optimistic‑state logic.

A broader pattern

Any code that interacts with an external source (server, API, user action) faces uncertainty. You can either:

  1. Let the uncertainty propagate through many functions/components.
  2. Contain it at the boundary where it enters your system.
  • Zod does this for server‑side data: validate at the endpoint, then downstream code works with clean, typed data.
  • useOptimistic does the same for UI state: declare the optimistic change right where the user acts (inside startTransition). The hook keeps the UI in sync with the prop, so downstream components only render.

Result: one clear boundary, one place to think about the optimistic update, and a single point to debug.

👉 Try it in practice: useOptimistic

0 views
Back to Blog

Related posts

Read more »