React 19 Optimistic UI: Declaratively Handling Form Submission, Errors, and Automatic Rollback with useOptimistic + Server Actions (Next.js 15 App Router)
If you're still managing optimistic UI manually, this post will save you time. I've been there myself — creating separate local state with useState, writing manual rollback logic inside try/catch. As the code piled up, questions like "where did this loading state come from?" and "who cleans up this temporary item when an error occurs?" kept multiplying, until I found myself in the bizarre situation where state management code outnumbered business logic.
With React 19's stable release, this problem has been solved quite elegantly. The combination of useOptimistic, useActionState, useFormStatus, and Server Actions has become the standard pattern for declaratively handling form submission → optimistic update → error recovery → automatic rollback, and in a Next.js 15 App Router environment, this pattern eliminates the need for separate API Routes entirely. By the end of this post, you'll be able to write code that handles optimistic updates, error display, and automatic rollback with a single Server Action.
This post is aimed at intermediate frontend developers who have some experience with React hooks and the Next.js App Router. If 'use server' and 'use client' are unfamiliar, here's a one-sentence summary — these two directives are boundary markers in the Next.js App Router that specify whether code runs on the server or in the client browser. Understanding this boundary makes the patterns below much more natural to read.
Core Concepts
Why Optimistic UI?
In any mutation that involves a network round-trip, the experience of "the UI freezing until the server responds" gives users the impression that the app is slow. This is especially true for low-failure-probability actions like posting a comment, clicking a like, or adding a to-do item.
Optimistic UI flips this assumption. The strategy is: "assume success and update the UI immediately, then roll back only if it actually fails." While this dramatically improves perceived performance, it introduces the challenge of handling rollback on failure. Before React 19, developers had to solve this themselves, but now the framework handles a significant portion of it.
The Role of Each API
When you first encounter these APIs, their roles can seem overlapping. I was confused myself when I first encountered this pattern in practice — it helps to map out what each hook does clearly.
| API | Source | Role |
|---|---|---|
useOptimistic |
react |
Generates optimistic state during async operations, automatically restores on completion/failure |
useActionState |
react |
Subscribes to Server Action return values and pending state (replaces the old useFormState) |
useFormStatus |
react-dom |
Tracks submission state of the nearest parent <form> |
| Server Actions | Next.js / React | 'use server' functions, internally invoked as HTTP POST |
useFormState→useActionStatemigration: In React 19,useFormStatehas been consolidated intouseActionState. If you're maintaining an existing codebase, it's recommended to replace it withuseActionStatefrom thereactpackage. The API signature is nearly identical.
How useOptimistic Works
const [optimisticState, addOptimistic] = useOptimistic(
state, // actual state (ground truth from server/parent)
(currentState, action) => nextOptimisticState // pure function
);The function in the second argument is the key. The moment you call addOptimistic(action), React immediately executes this function, creates the optimistic state, and reflects it in the render.
When the async operation completes — whether it succeeds or fails — React automatically restores to the state (actual state) in the first argument. In the success case, revalidatePath purges the server cache, and Next.js re-renders the parent Server Component to pass down updated data as props — replacing initialComments with new values, for instance. In the failure case, it simply returns to the original state with no further action. This means developers don't need to write rollback code themselves.
What automatic rollback means: The optimistic state from
useOptimisticis a temporary view that exists only within the scope of the async operation. Once the operation ends, React always overwrites it with the actual state.
One more thing worth noting — in React 19, if you connect an async function to a form action with <form action={asyncFn}>, React implicitly runs it inside a transition context. This means addOptimistic works correctly without needing a separate useTransition and startTransition. However, when calling it directly from a button click event rather than a form, it's safer to wrap it explicitly with startTransition.
Practical Application
Example 1: Comment Submission Form — Basic Pattern
This is the most typical scenario. When a comment is written, it appears in the list immediately before the server responds, and disappears if it fails.
First, define the Server Action.
// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache';
type CommentActionState =
| { error: string }
| { success: true; comment: { id: number; content: string; createdAt: string } }
| null;
export async function addComment(
prevState: CommentActionState,
formData: FormData
): Promise<CommentActionState> {
const content = formData.get('content') as string;
if (!content.trim()) {
return { error: 'Please enter some content.' };
}
try {
// Assumes Prisma ORM
const comment = await db.comment.create({
data: { content, createdAt: new Date() },
});
revalidatePath('/post/[id]', 'page');
return {
success: true,
comment: { ...comment, createdAt: comment.createdAt.toISOString() },
};
} catch {
return { error: 'Failed to save comment. Please try again later.' };
}
}The prevState type is explicitly declared as CommentActionState instead of any. For Server Action input/output types to be inferred end-to-end in TypeScript strict mode, this part needs to be accurate. The reason .toISOString() is used instead of returning a Date object directly is the same — React's serialization constraints.
Compose it in the client component.
// app/components/CommentForm.tsx
'use client'
import { useOptimistic, useActionState } from 'react';
import { useFormStatus } from 'react-dom';
import { addComment } from '../actions';
type Comment = { id: number | string; content: string; pending?: boolean };
export function CommentForm({ initialComments }: { initialComments: Comment[] }) {
const [optimisticComments, addOptimisticComment] = useOptimistic(
initialComments,
(state: Comment[], newComment: Comment) => [
...state,
{ ...newComment, pending: true },
]
);
const [state, formAction, isPending] = useActionState(addComment, null);
async function handleSubmit(formData: FormData) {
const content = formData.get('content') as string;
// In production environments with rapid successive submissions, crypto.randomUUID() is recommended
addOptimisticComment({ id: Date.now(), content });
await formAction(formData);
}
return (
<>
<ul>
{optimisticComments.map((c) => (
<li
key={c.id}
style={{ opacity: c.pending ? 0.5 : 1, transition: 'opacity 0.2s' }}
>
{c.content}
{c.pending && <span aria-label="Saving"> ⏳</span>}
</li>
))}
</ul>
<form action={handleSubmit}>
<input name="content" placeholder="Enter a comment" />
<SubmitButton />
{state?.error && (
<p role="alert" style={{ color: 'red' }}>
{state.error}
</p>
)}
</form>
</>
);
}
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Saving...' : 'Post Comment'}
</button>
);
}There's a reason SubmitButton is extracted as a separate component. useFormStatus must be called from a child component of <form>. If called above the <form> within the same component, it will always return pending: false. I struggled with this myself at first.
If you need to reset the form input field after a successful submission, you can either force a remount with <form key={isPending ? 'submitting' : 'idle'}> or call ref.current.reset() inside a useEffect. Since it's better UX to preserve input values on errors, it's recommended to add logic that resets only on success.
The Most Common Mistakes in Practice
While the code is fresh in mind, here are some gotchas to watch out for.
1. Calling useFormStatus in the same component as the form
useFormStatus only works from within a child component inside a <form>. If you call useFormStatus in the same component where <form> is rendered, it will always return pending: false. You need to extract the submit button as a separate component, as in the example above, for it to work correctly.
2. Calling addOptimistic outside a transition context
The form action={asyncFn} pattern works without a separate startTransition because React 19 implicitly sets up a transition. However, if you call addOptimisticComment directly from a button click event rather than a form, it's safer to wrap it with startTransition.
3. Including non-serializable types in Server Action return values
Returning a Date object directly will cause a serialization error on the client. Convert it with .toISOString(), or define the return type explicitly so TypeScript catches it at compile time. If you use prevState: any, you lose this safety net, so it's recommended to define types carefully.
Example 2: Production Environment — Combined with next-safe-action
To be honest, the pattern in Example 1 works on its own, but in a real service you can't skip authentication checks, Zod schema validation, and error logging. Writing these repeatedly for every Server Action quickly accumulates boilerplate. next-safe-action solves this cleanly — declare the auth middleware and Zod validation once and they're automatically applied to all Server Actions. It wasn't until I adopted this library that the Server Actions pattern truly felt convenient.
// lib/safe-action.ts
import { createSafeActionClient } from 'next-safe-action';
import { getServerSession } from 'next-auth';
export const authActionClient = createSafeActionClient().use(async ({ next }) => {
const session = await getServerSession();
if (!session?.user) throw new Error('Unauthorized');
return next({ ctx: { userId: session.user.id } });
});// app/todos/actions.ts
'use server'
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { authActionClient } from '@/lib/safe-action';
interface Todo {
id: number;
title: string;
completed: boolean;
userId: string;
}
export const createTodoAction = authActionClient
.schema(z.object({ title: z.string().min(1, 'Please enter a title.') }))
.action(async ({ parsedInput, ctx }) => {
// Assumes Prisma ORM
const todo: Todo = await db.todo.create({
data: { title: parsedInput.title, userId: ctx.userId },
});
revalidatePath('/todos');
return todo;
});// app/todos/TodoList.tsx
'use client'
import { useOptimisticAction } from 'next-safe-action/hooks';
import { createTodoAction } from './actions';
interface Todo {
id: number;
title: string;
completed: boolean;
pending?: boolean;
}
export function TodoList({ todos }: { todos: Todo[] }) {
const { execute, optimisticState, isPending } = useOptimisticAction(
createTodoAction,
{
currentState: { todos },
updateFn: (state, input) => ({
todos: [
...state.todos,
{ id: -1, title: input.title, completed: false, pending: true },
],
}),
}
);
function handleAdd(formData: FormData) {
const title = formData.get('title') as string;
execute({ title });
}
return (
<>
<ul>
{optimisticState.todos.map((todo) => (
<li key={todo.id} style={{ opacity: todo.pending ? 0.5 : 1 }}>
{todo.title}
</li>
))}
</ul>
<form action={handleAdd}>
<input name="title" placeholder="Add a to-do" />
<button type="submit" disabled={isPending}>Add</button>
</form>
</>
);
}useOptimisticAction wraps useOptimistic + useActionState + error handling into a single hook. The code is more concise than composing them manually, and Zod validation errors are automatically categorized under result.validationErrors.
Pros and Cons
Advantages
| Item | Description |
|---|---|
| Immediate feedback | UI responds instantly without network round-trips, dramatically improving perceived performance |
| Automatic rollback | React automatically restores previous state on Server Action failure — no manual rollback code needed |
| Declarative error handling | Error messages bind directly to UI via useActionState return values |
| Progressive Enhancement | Works as native HTML form even in environments with JS disabled |
| Type safety | Server Action input/output types are inferred end-to-end in TypeScript strict mode |
| Reduced boilerplate | A single useActionState replaces 3–4 useState calls, eliminating separate API Routes and fetch calls |
Disadvantages and Caveats
| Item | Description | Mitigation |
|---|---|---|
| Serialization constraints | Server Action arguments/return values must be React-serializable types | Convert Date to ISO strings, Map/Set to arrays before passing |
| Payload size limit | Payloads over 5MB may time out | Handle file uploads separately using Presigned URLs |
| HTTP POST only | Server Actions are always called as POST | Use searchParams or Route Handlers for GET-based search/read operations |
| Complex rollback scenarios | Partial success, optimistic conflict resolution cannot be handled by automatic rollback | Manual state adjustment based on server response, or use TanStack Query's onMutate pattern |
| Known bug | Reports of unexplained rollbacks in early React 19 versions (Issue #31967) | Use the latest patch version |
| Client Component required | useOptimistic can only be used in Client Components |
Design 'use client' boundaries carefully, keep Server Components as parents |
| Revalidation cost | revalidatePath calls purge the server cache |
Minimize path scope, use revalidateTag for finer-grained control when possible |
The constraint I encountered most often in practice was the serialization issue. I ran into runtime errors more than once by not accounting for Date types being returned directly from Prisma, and it wasn't until I developed the habit of explicitly defining return types that this problem noticeably decreased.
revalidateTag: WhilerevalidatePathinvalidates the entire cache for a given path,revalidateTagselectively invalidates only fetch requests that were pre-tagged. Useful when fine-grained cache control is needed.
Closing Thoughts
The combination of useOptimistic and Server Actions eliminates most of the boilerplate previously required for optimistic UI, and lets a single useActionState replace 3–4 useState calls. Once you're comfortable with this pattern, you'll find yourself thinking "what interaction do I want to create?" rather than "what do I show while waiting for the server response?"
Three steps you can start with right now:
- Pick one existing form in your project and convert it to a Server Action — create
app/actions.ts, move the logic to a'use server'async function, and by just connecting it withuseActionState, you'll immediately feel the difference as 3–4useStatecalls forloading/errormanagement disappear. - Add
useOptimisticfor optimistic updates — if you want to see the failure case, addif (Math.random() > 0.5) throw new Error('test error')inside the Server Action and verify that rollback happens automatically. - For production services, consider adopting
next-safe-action— install it withpnpm add next-safe-action zod, connect the auth middleware and Zod schema, and you get type safety and security in one shot.
References
- useOptimistic – React Official Docs
- useActionState – React Official Docs
- React v19 Official Release Notes
- Next.js Official: Forms and Server Actions Guide
- next-safe-action Official Docs – useOptimisticAction()
- React 19 useOptimistic Deep Dive — DEV Community
- React 19 useOptimistic: The Complete Guide for Production SaaS — DEV Community
- How to Use the Optimistic UI Pattern with the useOptimistic() Hook — freeCodeCamp
- Building an Optimistic UI Task Management App with React 19 and Next.js — Syncfusion
- React 19 useOptimistic Known Bug Issue #31967 — GitHub
- Next.js Server Actions: The Complete Guide (2026) — makerkit.dev