Skip to content
Codeloom
React

React useOptimistic: Instant UI Updates Before the Server

Learn React's useOptimistic hook for optimistic UI updates: how it works, when to use it, rollback strategies, and real-world patterns with Server Actions.

·6 min read · By Codeloom
Intermediate 10 min read

What you'll learn

  • What useOptimistic does and how it differs from useState
  • How to wire useOptimistic with Server Actions
  • Rollback behavior when mutations fail
  • Patterns for lists, toggles, and form submissions
  • When optimistic updates hurt more than they help

Prerequisites

None — this post is self-contained.

Users hate waiting. When someone clicks a like button, they expect the heart to fill immediately, not after a 200ms round trip. Optimistic updates solve this by updating the UI before the server confirms the change. React’s useOptimistic hook makes this pattern first-class.

How useOptimistic works

The hook takes the current state and a reducer function. It returns the optimistic state and a function to apply optimistic updates.

const [optimisticMessages, addOptimisticMessage] = useOptimistic(
  messages,
  (currentMessages, newMessage: string) => [
    ...currentMessages,
    { text: newMessage, sending: true },
  ]
);

When you call addOptimisticMessage, React immediately updates the UI with the reducer’s return value. When the parent component re-renders with new messages (after the server confirms), the optimistic state is replaced by the real state. If the action fails and messages does not change, the optimistic update is rolled back automatically.

The key insight: useOptimistic is tied to the lifecycle of a transition or Server Action. The optimistic state lives only until the action completes.

A complete example: message sending

Here is a chat interface that shows messages instantly while they are being sent to the server.

'use client';
import { useOptimistic } from 'react';
import { sendMessage } from './actions';

type Message = {
  id: string;
  text: string;
  sending?: boolean;
};

export function Chat({ messages }: { messages: Message[] }) {
  const [optimisticMessages, addOptimisticMessage] = useOptimistic(
    messages,
    (state, newText: string) => [
      ...state,
      { id: crypto.randomUUID(), text: newText, sending: true },
    ]
  );

  async function handleSubmit(formData: FormData) {
    const text = formData.get('message') as string;
    addOptimisticMessage(text);
    await sendMessage(text);
  }

  return (
    <div>
      <ul>
        {optimisticMessages.map((msg) => (
          <li key={msg.id} style={{ opacity: msg.sending ? 0.6 : 1 }}>
            {msg.text}
            {msg.sending && <span className="sending-indicator"> Sending...</span>}
          </li>
        ))}
      </ul>
      <form action={handleSubmit}>
        <input name="message" required />
        <button type="submit">Send</button>
      </form>
    </div>
  );
}

The Server Action:

'use server';
import { revalidatePath } from 'next/cache';
import { db } from '@/db';

export async function sendMessage(text: string) {
  await db.messages.insert({ text, createdAt: new Date() });
  revalidatePath('/chat');
}

When the user submits, addOptimisticMessage fires synchronously. The message appears in the list immediately with reduced opacity. When sendMessage completes and the page revalidates, the real message from the database replaces the optimistic one.

Toggle pattern: likes and bookmarks

Toggles are the most common optimistic update. A user clicks a button, the state flips instantly, and the server catches up.

'use client';
import { useOptimistic } from 'react';
import { toggleLike } from './actions';

export function LikeButton({ liked, count }: { liked: boolean; count: number }) {
  const [optimistic, setOptimistic] = useOptimistic(
    { liked, count },
    (state, _action: void) => ({
      liked: !state.liked,
      count: state.liked ? state.count - 1 : state.count + 1,
    })
  );

  async function handleClick() {
    setOptimistic();
    await toggleLike();
  }

  return (
    <form action={handleClick}>
      <button type="submit">
        {optimistic.liked ? 'Unlike' : 'Like'} ({optimistic.count})
      </button>
    </form>
  );
}

The reducer ignores the action parameter because a toggle only needs to know the current state. The count updates in sync with the liked status.

List operations: add, remove, reorder

For lists, the reducer handles the specific operation:

'use client';
import { useOptimistic } from 'react';
import { deleteTask } from './actions';

type Task = { id: string; title: string; pending?: boolean };

export function TaskList({ tasks }: { tasks: Task[] }) {
  const [optimisticTasks, updateTasks] = useOptimistic(
    tasks,
    (state, deletedId: string) =>
      state.filter((t) => t.id !== deletedId)
  );

  async function handleDelete(taskId: string) {
    updateTasks(taskId);
    await deleteTask(taskId);
  }

  return (
    <ul>
      {optimisticTasks.map((task) => (
        <li key={task.id}>
          {task.title}
          <form action={() => handleDelete(task.id)}>
            <button type="submit">Delete</button>
          </form>
        </li>
      ))}
    </ul>
  );
}

The task disappears from the list the moment the user clicks delete. If the server call fails and the parent re-renders with the original tasks, the deleted task reappears.

Rollback behavior

Rollback in useOptimistic is automatic. When the action (transition or Server Action) completes, the optimistic state is discarded and the real state from the parent takes over. If the action succeeded, the real state matches what the user already sees. If it failed, the real state reverts to the previous value.

This means you do not write rollback logic yourself. But you should handle the user experience of a rollback. A task that reappears after being deleted needs an explanation.

async function handleDelete(taskId: string) {
  updateTasks(taskId);
  try {
    await deleteTask(taskId);
  } catch {
    // The optimistic state will revert automatically.
    // Show the user what happened.
    toast.error('Failed to delete task. It has been restored.');
  }
}

Combining with useTransition

useOptimistic works naturally inside startTransition. This gives you both the optimistic update and the pending state:

'use client';
import { useOptimistic, useTransition } from 'react';

export function StatusToggle({ isActive }: { isActive: boolean }) {
  const [isPending, startTransition] = useTransition();
  const [optimisticActive, setOptimistic] = useOptimistic(isActive);

  function handleToggle() {
    startTransition(async () => {
      setOptimistic(!isActive);
      await updateStatus(!isActive);
    });
  }

  return (
    <button onClick={handleToggle} disabled={isPending}>
      {optimisticActive ? 'Active' : 'Inactive'}
    </button>
  );
}

When not to use optimistic updates

Optimistic updates assume the action will succeed. They work well for low-stakes operations with high success rates: likes, bookmarks, sending messages, toggling preferences.

They work poorly when the action frequently fails (payment processing, complex validations), when the action has side effects that cannot be visually faked (sending an email, generating a PDF), or when the data depends on server-computed values (IDs, timestamps, computed fields that affect display).

For these cases, a pending state with a spinner or disabled button is more honest and less confusing than an optimistic update that rolls back. The user trusts a spinner. They distrust a button that undoes itself.

Summary

useOptimistic is a focused hook for a focused problem. It lets you update the UI before the server responds, with automatic rollback if the server disagrees. Pair it with Server Actions for mutations, use the sending flag to style pending items, and save it for operations where the success rate justifies the optimism.