Next.js Optimistic UI with useOptimistic, useActionState, and useFormStatus (2026)

Build instant-feeling forms and lists in Next.js with useOptimistic, useActionState, and useFormStatus. Production patterns, type-safe rollback, and real Server Action examples.

Next.js useOptimistic Guide (2026)

Updated: May 26, 2026

Next.js optimistic UI means updating the interface before a server response comes back, using React 19's useOptimistic hook to render a predicted state and then reconciling with the real server result. Paired with useActionState and useFormStatus, it lets you build instant-feeling forms, toggle buttons, and lists on top of Server Actions without manually juggling loading flags. This guide walks through the production patterns I actually ship: type-safe mutations, automatic rollback on errors, and how to combine all three hooks in real App Router code.

  • useOptimistic renders a predicted UI state while a Server Action is in flight and automatically reverts when the transition ends.
  • useActionState (renamed from useFormState in React 19) tracks the latest Server Action result, including validation errors and success payloads.
  • useFormStatus exposes pending from inside any descendant of a <form>, so submit buttons can disable themselves without prop drilling.
  • Errors thrown inside a Server Action automatically roll back the optimistic state, but you still have to surface them to the user via useActionState or a toast.
  • Optimistic updates are safe for idempotent mutations (likes, toggles, list inserts with client-side IDs). They are risky for non-idempotent ones (payments).
  • Always wrap optimistic mutations in startTransition or call them from inside a form action so React batches the update correctly.

What is useOptimistic in React 19?

useOptimistic is a React 19 hook that lets you show a temporary "predicted" version of state while an asynchronous action (typically a Server Action) is running. It returns a tuple of [optimisticState, addOptimistic]. The first value is what your UI should render right now, and the second is a function you call to apply a speculative change. When the surrounding transition finishes (whether the action resolved, threw, or was superseded), React automatically discards the optimistic value and re-renders with whatever real state the parent passed in.

Here's the thing that surprised me the first time I used it: the hook does not persist anything. It's a per-render fork of state that lives only for the duration of one or more concurrent transitions. So it pairs naturally with Server Actions, which already trigger a transition and a re-fetch when they call revalidatePath or revalidateTag. If you want a deeper foundation on how those mutations work, the Next.js Server Actions production guide covers the mutation lifecycle, security, and revalidation contracts that useOptimistic rides on top of.

The minimal signature looks like this:

const [optimisticState, addOptimistic] = useOptimistic(
  state,                          // the "real" state from props
  (currentState, optimisticValue) => nextState // reducer
);

Two things to internalize. First, the reducer runs on every render that has a pending optimistic update, so it must be pure. Second, addOptimistic can only be called from inside a transition: a form's action prop, an onSubmit handler wrapped in startTransition, or directly inside an async function passed to useActionState. Calling it outside one is a no-op, and React will warn you in development.

useActionState vs useFormState: what changed

useActionState is the React 19 replacement for useFormState. The signature and semantics are almost identical, but the new name signals that it works with any async action, not just ones wired to a <form>. It returns [state, dispatch, isPending], where dispatch is the wrapped action you assign to form action={dispatch}, and isPending is a boolean you can use to disable buttons, show spinners, or trigger transitions in nearby components.

Here's the canonical pattern in a Client Component:

'use client';
import { useActionState } from 'react';
import { createTodo } from './actions';

type State = { error?: string; success?: boolean };

export function TodoForm() {
  const [state, formAction, isPending] = useActionState<State, FormData>(
    createTodo,
    {}
  );

  return (
    <form action={formAction}>
      <input name="title" required />
      <button disabled={isPending}>
        {isPending ? 'Adding…' : 'Add todo'}
      </button>
      {state.error && <p role="alert">{state.error}</p>}
    </form>
  );
}

The Server Action's signature must accept the previous state as its first argument and return the next state. That's what makes validation errors round-trip cleanly, without you writing any client-side fetch logic:

'use server';
import { z } from 'zod';

const Schema = z.object({ title: z.string().min(1).max(140) });

export async function createTodo(_prev: State, formData: FormData): Promise<State> {
  const parsed = Schema.safeParse(Object.fromEntries(formData));
  if (!parsed.success) {
    return { error: parsed.error.issues[0].message };
  }
  await db.insert(todos).values({ title: parsed.data.title });
  revalidatePath('/todos');
  return { success: true };
}

If you're already using a validation library client-side, the Next.js forms with React Hook Form and Zod guide shows how to keep schema definitions shared between the form and the action. That becomes important once your forms grow beyond three fields (which, honestly, happens fast).

Pending submit buttons with useFormStatus

useFormStatus is the third leg of the form trio. Unlike useActionState, it doesn't take any arguments. It simply reads the pending state of the nearest ancestor <form>. That makes it ideal for self-contained submit buttons that don't want to know about the parent's state shape:

'use client';
import { useFormStatus } from 'react-dom';

export function SubmitButton({ children }: { children: React.ReactNode }) {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending} aria-busy={pending}>
      {pending ? 'Saving…' : children}
    </button>
  );
}

One subtlety that trips people up (and I hit this exact bug shipping a comment form): useFormStatus must be called from a component rendered inside the form. If you call it from the same component that renders the <form>, it always returns { pending: false }. That's by design. React reads the form context from the component tree, and the form's own component is technically a sibling of the form element, not a child of it.

You can also read data, method, and action from the same hook, which is useful if you want to render a preview of what's being submitted (for example, a "Posting comment: …" indicator) without lifting state. Combined with useActionState, you get a clean separation where the parent owns the state shape and the button owns its own pending UI, with zero prop drilling.

Building an optimistic todo list end-to-end

So, let's put the three hooks together. We'll build a todo list where new items appear instantly, complete with a "pending" visual treatment that disappears once the server confirms. The Server Component fetches the real list:

// app/todos/page.tsx
import { db } from '@/lib/db';
import { TodoList } from './todo-list';

export default async function TodosPage() {
  const todos = await db.query.todos.findMany({
    orderBy: (t, { desc }) => desc(t.createdAt),
  });
  return <TodoList todos={todos} />;
}

The Client Component owns the optimistic state. Notice that the optimistic reducer accepts a typed action. This is the pattern that scales beyond two operations:

'use client';
import { useOptimistic, useRef } from 'react';
import { createTodo, toggleTodo } from './actions';

type Todo = { id: string; title: string; done: boolean; pending?: boolean };
type Action =
  | { type: 'add'; todo: Todo }
  | { type: 'toggle'; id: string };

export function TodoList({ todos }: { todos: Todo[] }) {
  const formRef = useRef<HTMLFormElement>(null);

  const [optimisticTodos, addOptimistic] = useOptimistic<Todo[], Action>(
    todos,
    (state, action) => {
      switch (action.type) {
        case 'add':
          return [{ ...action.todo, pending: true }, ...state];
        case 'toggle':
          return state.map(t =>
            t.id === action.id ? { ...t, done: !t.done, pending: true } : t
          );
      }
    }
  );

  async function handleCreate(formData: FormData) {
    const title = String(formData.get('title') ?? '');
    if (!title.trim()) return;
    addOptimistic({
      type: 'add',
      todo: { id: crypto.randomUUID(), title, done: false },
    });
    formRef.current?.reset();
    await createTodo(formData);
  }

  return (
    <>
      <form ref={formRef} action={handleCreate}>
        <input name="title" placeholder="What needs doing?" />
      </form>
      <ul>
        {optimisticTodos.map(todo => (
          <li key={todo.id} style={{ opacity: todo.pending ? 0.5 : 1 }}>
            <form
              action={async () => {
                addOptimistic({ type: 'toggle', id: todo.id });
                await toggleTodo(todo.id);
              }}
            >
              <button>{todo.done ? '✓' : '○'} {todo.title}</button>
            </form>
          </li>
        ))}
      </ul>
    </>
  );
}

The key detail: the handleCreate function calls addOptimistic before awaiting the Server Action. Because it's used as a form action, React wraps the whole function in a transition automatically. The optimistic todo renders immediately with pending: true, and when createTodo resolves and revalidatePath fires, the parent Server Component re-fetches and the real todo replaces the optimistic one. If the action throws, the optimistic state is discarded and the list snaps back to its server-truth value.

How do you handle errors with useOptimistic?

The most-asked question about useOptimistic is also the most misunderstood: rollback is automatic, but error surfacing is not. When a Server Action throws or returns an error state, React tears down the optimistic value. But your user just saw a todo appear and then disappear with no explanation. You need to tell them what happened.

There are two production patterns I lean on. The first is to combine useOptimistic with useActionState and render the error state alongside the list:

const [actionState, formAction] = useActionState(createTodo, {});

async function handleCreate(formData: FormData) {
  addOptimistic({ type: 'add', todo: /* … */ });
  await formAction(formData);
}

return (
  <>
    {actionState.error && (
      <div role="alert" className="error-banner">
        Could not save: {actionState.error}
      </div>
    )}
    {/* …rest of UI… */}
  </>
);

The second pattern is to fire a toast inside a try/catch around the action call. That's cleaner for fire-and-forget mutations (like a "like" button), where you don't want the error to live on the page. Either way, the rule is: treat useOptimistic as presentation only, never as your source of truth for "did it succeed?". For multi-step flows where state must survive across re-renders and route changes, route to a dedicated error boundary. The App Router error handling guide covers error.tsx patterns that pair well with this.

One thing to watch for: if your Server Action returns an error state (instead of throwing) and that state still triggers revalidatePath, the optimistic value won't roll back, because React sees a successful action. Either throw on validation failures, or don't revalidate when returning an error.

Type-safe optimistic updates with TypeScript

The two generics on useOptimistic<State, Action> are how you keep the reducer honest. In the todo example above, we defined an Action union so the compiler refuses any call to addOptimistic that doesn't match one of the cases. This catches the most common bug: typoing an action type and silently rendering stale state.

For mutations that mostly look like CRUD, a small set of helpers is worth extracting. Here's a reusable pattern for any "list of things with stable ids":

type Listable = { id: string };
type ListAction<T extends Listable> =
  | { type: 'create'; item: T }
  | { type: 'update'; id: string; patch: Partial<T> }
  | { type: 'delete'; id: string };

function listReducer<T extends Listable>(
  state: T[],
  action: ListAction<T>
): T[] {
  switch (action.type) {
    case 'create': return [action.item, ...state];
    case 'update': return state.map(x =>
      x.id === action.id ? { ...x, ...action.patch } : x
    );
    case 'delete': return state.filter(x => x.id !== action.id);
  }
}

Then in any component: const [list, dispatch] = useOptimistic(items, listReducer<Comment>). Because the reducer is generic over T, you reuse it for comments, todos, files, anything with an id. Drizzle's inferred types compose particularly well here. The Drizzle ORM integration guide walks through generating those row types from your schema, so the optimistic state and the database stay in lockstep.

For client-generated ids (used when inserting a row before the server assigns one), crypto.randomUUID() in modern browsers is fine for ephemeral display. If your server uses sequential ids or ULIDs, generate the same format client-side so the optimistic row's key doesn't change when the real one arrives. That prevents React from unmounting and remounting the list item mid-animation.

When you should not use optimistic UI

Optimistic updates are wrong for any mutation where the user can't safely assume success. That includes payments, irreversible deletes that cascade, anything involving third-party APIs with rate limits or quotas, and write operations that depend on server-only validation (uniqueness checks, role permissions, race-condition-sensitive counters). In all those cases, show a loading state (usually via useFormStatus) and only update the UI when the server confirms.

A useful heuristic from the React team: if the failure mode of "the UI lied to the user for 300ms" is acceptable, optimistic UI is fine. If it would lead to a support ticket, it isn't. Likes, reactions, drag-to-reorder, marking items read, toggling settings: all great candidates. Submitting a $400 checkout, deleting a database, scheduling an email blast: all bad ones. The official React useOptimistic reference covers the conceptual model and edge cases in more depth.

There's also a performance angle. If your list has 10,000 items and the reducer creates a new array on every render, you're paying that cost on every keystroke and every action. Either virtualize the list (TanStack Virtual or react-window) or store only the deltas optimistically and merge them at render time with useMemo.

Common mistakes and how to avoid them

After reviewing real codebases (and a couple of my own embarrassing PRs), four bugs come up over and over. First, calling addOptimistic outside a transition, usually from a click handler that isn't a form action. The fix is either wrapping the call in startTransition from useTransition, or restructuring to use a one-button form. Second, forgetting that useOptimistic resets on prop change: if the parent re-fetches and passes new props, all pending optimistic values are discarded. That's intentional, but it surprises people who treat it like useState.

Third, mutating the state inside the reducer. The reducer must be pure and return new references. Mutating state.push(item) works the first time, then breaks because React's bailout logic sees the same array reference and skips the re-render. Always return a fresh array or object. Fourth, not handling the offline case: if the user's network drops, the Server Action hangs, and your optimistic UI sits there indefinitely. Wrap the action in a timeout and reject after, say, 10 seconds, so the optimistic value rolls back and you can show an error.

For a deeper look at the underlying behavior changes, the React 19 release announcement documents how Actions, transitions, and the new hooks compose. The Next.js Server Actions reference spells out the revalidation contract that closes the loop on every optimistic mutation.

Frequently Asked Questions

What is the difference between useOptimistic and useState?

useState persists across renders until you explicitly change it. useOptimistic is forked from a "real" state value you pass in, and React automatically discards the optimistic value once the surrounding transition ends. Use useState for client-only data and useOptimistic for predicting the outcome of an in-flight server mutation.

Can you use useOptimistic without Server Actions?

Yes. Any async function called inside startTransition, including a plain fetch or a TanStack Query mutation, will trigger the optimistic update and roll it back when it resolves. Server Actions are the most ergonomic match because they already run inside a transition, but the hook isn't coupled to them.

Why isn't my useFormStatus pending flag updating?

The hook reads the nearest ancestor <form> in the component tree, so it must be called from a component rendered inside the form. If you call it in the same component that returns the <form> element, it always returns pending: false. Extract the submit button into a child component.

Does useOptimistic work with useTransition?

Yes, and in fact addOptimistic must be called inside a transition. If you're not using a form's action prop, wrap your handler in startTransition from useTransition. The hook will track the transition's pending state and roll back when it resolves or errors.

Should I use useOptimistic or useActionState for form validation errors?

Use useActionState. Validation errors are server-confirmed state, not a prediction, so they belong in the action's return value. Use useOptimistic only for the UI changes you're confident will succeed (the new item appearing in a list), and let useActionState carry the error message back to the form.

Editorial Team
About the Author Editorial Team

Our team of expert writers and editors.