How to make Server Actions feel instant — useOptimistic for immediate UI updates, useActionState for form state management, pending state patterns, and rolling back on error. The bridge between server-actions-mutations and at-scale.
Optimistic UI: useOptimistic, useActionState, and the Pending State Pattern
Who this module is for: You have Server Actions working — the form submits, the data saves, the page updates. But the UI feels sluggish: users click a button and stare at a spinner while the server round-trip completes. This module covers the three React 19 hooks that make Server Actions feel instant — useOptimistic for immediate UI updates, useActionState for form state management, and the pending state pattern for visual feedback.
Why Optimistic UI Matters
A Server Action invokes a round-trip to your server: the browser sends a request, Next.js executes the action, revalidates the cache, and the updated UI flows back down. Even on a fast connection, this takes 200–800ms. On a mobile network, 1–3 seconds.
Users have been conditioned by native apps and real-time UIs to expect instant feedback. A button that does nothing for 500ms feels broken. Optimistic UI solves this by updating the UI immediately — before the server confirms — and rolling back if the server reports an error.
The pattern has three layers:
- Pending state — show that something is happening (spinner, disabled state)
- Optimistic update — show the expected result immediately
- Rollback — revert if the server returns an error
React 19 provides a hook for each layer.
useActionState: Form State Management
useActionState (formerly useFormState in React 18) connects a Server Action to a component's state. It gives you the action's return value and a wrapped action function that you pass to a form.
tsx'use client'; import { useActionState } from 'react'; import { createComment } from './actions'; type State = { error?: string; success?: boolean; comment?: { id: string; text: string }; }; export function CommentForm({ postId }: { postId: string }) { const [state, action, isPending] = useActionState<State, FormData>( createComment, {} // initial state ); return ( <form action={action}> <input type="hidden" name="postId" value={postId} /> <textarea name="text" required disabled={isPending} placeholder="Write a comment..." className="w-full border rounded p-2" /> {state.error && ( <p className="text-red-500 text-sm mt-1">{state.error}</p> )} <button type="submit" disabled={isPending}> {isPending ? 'Posting...' : 'Post comment'} </button> </form> ); }
typescript// app/actions.ts 'use server'; import { revalidatePath } from 'next/cache'; export async function createComment(prevState: State, formData: FormData): Promise<State> { const postId = formData.get('postId') as string; const text = formData.get('text') as string; if (!text || text.length < 3) { return { error: 'Comment must be at least 3 characters' }; } try { const comment = await db.comments.create({ postId, text }); revalidatePath(`/posts/${postId}`); return { success: true, comment }; } catch { return { error: 'Failed to post comment. Please try again.' }; } }
What useActionState gives you:
state— the most recent return value from the Server Action (or initial state on first render)action— a wrapped version of your Server Action to pass to<form action={action}>isPending—truewhile the action is in flight
The signature change: your Server Action must accept prevState as the first argument before formData. The prevState is the previous return value — useful for accumulating state across multiple submissions.
useOptimistic: Instant UI Updates
useOptimistic lets you show a temporary "optimistic" value while an async operation is pending, then automatically revert to the actual value when the operation completes.
tsx'use client'; import { useOptimistic, useActionState } from 'react'; import { toggleLike } from './actions'; type LikeButtonProps = { postId: string; initialLiked: boolean; initialCount: number; }; export function LikeButton({ postId, initialLiked, initialCount }: LikeButtonProps) { // The real state (synced with server) const [liked, setLiked] = React.useState(initialLiked); const [count, setCount] = React.useState(initialCount); // Optimistic state (shown immediately on click) const [optimisticLiked, addOptimisticLike] = useOptimistic( liked, (currentLiked, action: 'toggle') => !currentLiked // optimistic update function ); const [optimisticCount, addOptimisticCount] = useOptimistic( count, (currentCount, delta: number) => currentCount + delta ); async function handleLike() { // 1. Update UI immediately (optimistic) addOptimisticLike('toggle'); addOptimisticCount(optimisticLiked ? -1 : 1); // 2. Send to server const result = await toggleLike(postId); // 3. If server succeeded, sync real state if (result.success) { setLiked(result.liked); setCount(result.count); } // If server failed: useOptimistic automatically reverts to the real state // because the optimistic value only lives until the async operation resolves } return ( <button onClick={handleLike} className="flex items-center gap-2"> <span>{optimisticLiked ? '❤️' : '🤍'}</span> <span>{optimisticCount}</span> </button> ); }
How useOptimistic Works Internally
useOptimistic takes two arguments:
- The real state (synced with the server)
- An update function:
(currentState, optimisticValue) => newState
When you call addOptimistic(value):
- The component immediately re-renders with
updateFn(currentState, value) - While the associated async operation is pending, React shows the optimistic state
- When the async operation completes (or if the component re-renders with new real state), the optimistic state is discarded and the real state takes over
The rollback is automatic — if your Server Action throws, React reverts the optimistic update because the pending state resolves.
Combining All Three: A Complete Example
The canonical pattern: useActionState for form management + useOptimistic for immediate list updates:
tsx'use client'; import { useActionState, useOptimistic, useRef } from 'react'; import { addTodo } from './actions'; type Todo = { id: string; text: string; completed: boolean }; type State = { error?: string }; export function TodoList({ initialTodos }: { initialTodos: Todo[] }) { const formRef = useRef<HTMLFormElement>(null); // Real todo list (from server) const [todos, setTodos] = React.useState(initialTodos); // Optimistic todo list (shown immediately) const [optimisticTodos, addOptimisticTodo] = useOptimistic( todos, (current, newTodo: Todo) => [...current, newTodo] ); const [state, action, isPending] = useActionState<State, FormData>( async (prevState, formData) => { const text = formData.get('text') as string; // Optimistic update: add todo immediately addOptimisticTodo({ id: `temp-${Date.now()}`, // temporary ID text, completed: false, }); // Reset form immediately formRef.current?.reset(); // Call the actual server action const result = await addTodo(formData); if (result.error) { return { error: result.error }; // useOptimistic will revert because the real todos haven't changed } // Sync real state with server result setTodos(result.todos); return {}; }, {} ); return ( <div> <ul> {optimisticTodos.map((todo) => ( <li key={todo.id} className={todo.id.startsWith('temp-') ? 'opacity-60' : ''} > {todo.text} {todo.id.startsWith('temp-') && <span> (saving...)</span>} </li> ))} </ul> <form ref={formRef} action={action}> <input name="text" required placeholder="New todo..." /> {state.error && <p className="text-red-500">{state.error}</p>} <button type="submit" disabled={isPending}> {isPending ? 'Adding...' : 'Add'} </button> </form> </div> ); }
The temporary items render with reduced opacity and a "(saving...)" tag. When the server responds, setTodos(result.todos) updates the real state with the server-assigned IDs, and the optimistic overlay is replaced.
The Pending State Pattern
Beyond spinners, a well-designed pending state communicates exactly what is happening and prevents double-submission.
Disable the Trigger
Always disable the submit button (or the entire form) while isPending is true:
tsx<button type="submit" disabled={isPending} aria-busy={isPending}> {isPending ? ( <> <Spinner className="w-4 h-4 animate-spin" /> Saving... </> ) : ( 'Save' )} </button>
Skeleton vs Spinner
For content that takes > 300ms, a skeleton is better UX than a spinner. Use Suspense boundaries with skeleton fallbacks for the loading state:
tsx// For slow operations, use Suspense + skeleton <Suspense fallback={<CommentSkeleton />}> <CommentList postId={postId} /> </Suspense> // For fast optimistic operations, use inline pending state <LikeButton isPending={isPending} />
useFormStatus for Deep Components
useFormStatus (from react-dom) reads the pending state of the nearest parent <form> — useful for submit buttons nested inside form components:
tsx'use client'; import { useFormStatus } from 'react-dom'; // This component doesn't need to receive isPending as a prop export function SubmitButton({ children }: { children: React.ReactNode }) { const { pending } = useFormStatus(); return ( <button type="submit" disabled={pending} aria-busy={pending}> {pending ? 'Saving...' : children} </button> ); } // Usage: works automatically inside any form <form action={myAction}> <input name="title" /> <SubmitButton>Create Post</SubmitButton> </form>
Error Handling and Rollback Patterns
Explicit Rollback with Error Boundaries
For operations that must roll back cleanly (financial, irreversible):
tsxasync function handleDelete(id: string) { // Store the item before optimistic removal const itemToRestore = items.find(item => item.id === id); // Optimistic: remove immediately addOptimisticItems(items.filter(item => item.id !== id)); try { await deleteItem(id); setItems(prev => prev.filter(item => item.id !== id)); } catch { // Explicit rollback: restore the removed item setItems(prev => [...prev, itemToRestore!]); toast.error('Failed to delete. Please try again.'); } }
Toasts for Async Feedback
Optimistic UI removes the "loading" state, but users still need to know when something fails:
tsxconst [state, action] = useActionState(async (prev, formData) => { const result = await createPost(formData); if (result.error) { toast.error(result.error); return { error: result.error }; } toast.success('Post created!'); return { success: true }; }, {});
When NOT to Use Optimistic UI
Optimistic UI is a UX enhancement, not a correctness guarantee. Do not use it when:
- The operation is not idempotent — if showing the wrong result before server confirmation causes confusion (e.g., payment confirmations, inventory deduction)
- Rollback is disorienting — if the rollback experience is worse than waiting (e.g., the user navigated away expecting success)
- The operation is fast — if the server responds in < 100ms, optimistic UI adds complexity with no UX benefit
- You need the server response to continue — if the UI flow depends on a server-assigned ID or token, you must wait for the response
The useTransition Alternative
For non-form async operations (button clicks that trigger state transitions), useTransition provides isPending without the form machinery:
tsx'use client'; import { useTransition } from 'react'; import { publishPost } from './actions'; export function PublishButton({ postId }: { postId: string }) { const [isPending, startTransition] = useTransition(); function handlePublish() { startTransition(async () => { await publishPost(postId); }); } return ( <button onClick={handlePublish} disabled={isPending}> {isPending ? 'Publishing...' : 'Publish'} </button> ); }
useTransition marks the state update as non-urgent — React can interrupt it if higher-priority updates arrive (user input). Useful for transitions that should not block typing or other interactions.
Summary
useActionState— connects a Server Action to component state; gives youstate,action, andisPending; Server Action signature gainsprevStateas first paramuseOptimistic— shows an immediate temporary value; auto-reverts to real state when the async operation completes; rollback is automatic on erroruseFormStatus— reads pending state from the nearest parent form; avoids prop-drillingisPendinginto nested submit buttonsuseTransition— marks async state updates as non-urgent; providesisPendingfor non-form button interactions- The pattern: optimistic update → async server call → sync real state on success → auto-rollback on failure
- When to skip it: payment flows, irreversible operations, fast operations (< 100ms), flows that depend on server-assigned values
Next: P-4 — Authentication with Auth.js v5 — session management, OAuth providers, database sessions, and protecting routes with middleware.