Optimistic UI with useOptimistic

Update the UI instantly on a mutation and reconcile with the server result — using useOptimistic and useActionState together.

Optimistic UI makes an app feel instant: you show the expected result the moment the user acts, before the server confirms, then reconcile when the real answer arrives. React's useOptimistic hook, paired with Server Actions, makes this clean.

How useOptimistic works

You give it the current state and a reducer describing how an optimistic update transforms it. Inside a transition (which a Server Action triggers), you push an optimistic value; React shows it immediately, then automatically reverts to the real state once the action resolves and the server data flows back. If the action fails, the optimistic value simply disappears — no manual rollback.

The pairing

  • useOptimistic — the instant, temporary view.
  • useActionState — the pending flag and the authoritative returned result.
  • The Server Action — does the real write and revalidation.

Where it shines

Likes, todos, comments, reorderable lists — anything where the success path is overwhelmingly common and a rare failure reverting is acceptable. Do not use it for irreversible or high-stakes operations (payments) where a false 'success' flash is worse than a spinner.

Example

Example · typescript
'use client';
import { useOptimistic } from 'react';
import { addTodo } from './actions';

type Todo = { id: string; title: string; sending?: boolean };

export function TodoList({ todos }: { todos: Todo[] }) {
  const [optimistic, addOptimistic] = useOptimistic(
    todos,
    (state, newTitle: string) => [
      ...state,
      { id: 'temp', title: newTitle, sending: true },
    ],
  );

  async function action(formData: FormData) {
    const title = formData.get('title') as string;
    addOptimistic(title);      // shows instantly
    await addTodo(title);      // real write + revalidatePath
  }                            // optimistic item replaced by real data

  return (
    <>
      <ul>
        {optimistic.map((t) => (
          <li key={t.id} style={{ opacity: t.sending ? 0.5 : 1 }}>
            {t.title}
          </li>
        ))}
      </ul>
      <form action={action}>
        <input name="title" />
        <button>Add</button>
      </form>
    </>
  );
}

When to use it

  • A like button increments its count immediately on click via useOptimistic, then corrects to the server count once the action resolves.
  • A todo list adds a new item to the UI instantly while the Server Action persists it, without a loading spinner.
  • A cart add button shows the updated item count optimistically so the user feels instant feedback on slow network connections.

More examples

useOptimistic for a like button

useOptimistic immediately applies the increment, reverting only if the Server Action throws.

Example · ts
'use client';
import { useOptimistic, useTransition } from 'react';
import { likePost } from '@/app/actions';

export default function LikeButton({ postId, likes }: { postId: string; likes: number }) {
  const [optimisticLikes, addOptimistic] = useOptimistic(likes, (prev) => prev + 1);
  const [, startTransition] = useTransition();

  return (
    <button
      onClick={() => startTransition(async () => {
        addOptimistic(null);
        await likePost(postId);
      })}
    >
      {optimisticLikes} ❤️
    </button>
  );
}

Optimistic list insert

The optimistic item appears immediately with reduced opacity to signal it is pending server confirmation.

Example · ts
'use client';
import { useOptimistic } from 'react';
import { addTodo } from '@/app/actions';

type Todo = { id: string; text: string; pending?: boolean };

export default function TodoList({ todos }: { todos: Todo[] }) {
  const [optimisticTodos, addOptimistic] = useOptimistic(
    todos,
    (prev, newTodo: Todo) => [...prev, newTodo]
  );

  async function handleAdd(formData: FormData) {
    const text = formData.get('text') as string;
    addOptimistic({ id: 'temp', text, pending: true });
    await addTodo(text);
  }

  return (
    <>
      <ul>{optimisticTodos.map(t => <li key={t.id} style={{ opacity: t.pending ? 0.5 : 1 }}>{t.text}</li>)}</ul>
      <form action={handleAdd}><input name="text" /><button>Add</button></form>
    </>
  );
}

Combine with useActionState

Combining useActionState with useOptimistic gives both a pending flag and instant UI updates in one form.

Example · ts
'use client';
import { useActionState, useOptimistic } from 'react';
import { createComment } from '@/app/actions';

export default function CommentForm({ comments }: { comments: string[] }) {
  const [optimistic, addOptimistic] = useOptimistic(comments, (p, v: string) => [...p, v]);
  const [, action, pending] = useActionState(async (_prev: unknown, fd: FormData) => {
    addOptimistic(fd.get('text') as string);
    return createComment(fd);
  }, null);

  return (
    <>
      <ul>{optimistic.map((c, i) => <li key={i}>{c}</li>)}</ul>
      <form action={action}>
        <input name="text" />
        <button disabled={pending}>Post</button>
      </form>
    </>
  );
}

Discussion

  • Be the first to comment on this lesson.