Optimistic UI: useOptimistic, useActionState, and the Pending State Pattern18 min read
Module P-3·18 min read
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
typescript
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 — true while 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
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
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
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
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
Error Handling and Rollback Patterns
Explicit Rollback with Error Boundaries
For operations that must roll back cleanly (financial, irreversible):
tsx
Toasts for Async Feedback
Optimistic UI removes the "loading" state, but users still need to know when something fails:
tsx
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
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 you state, action, and isPending; Server Action signature gains prevState as first param
useOptimistic — shows an immediate temporary value; auto-reverts to real state when the async operation completes; rollback is automatic on error
useFormStatus — reads pending state from the nearest parent form; avoids prop-drilling isPending into nested submit buttons
useTransition — marks async state updates as non-urgent; provides isPending for 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.
Knowledge Check
How does useOptimistic handle rollbacks if the associated asynchronous Server Action throws an error?
When managing deep form components, what is the benefit of using useFormStatus from react-dom?
In which scenario is using Optimistic UI generally NOT recommended?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
// app/actions.ts'use server';import{ revalidatePath }from'next/cache';exportasyncfunctioncreateComment(prevState: State, formData: FormData):Promise<State>{const postId = formData.get('postId')asstring;const text = formData.get('text')asstring;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.'};}}
'use client';import{ useOptimistic, useActionState }from'react';import{ toggleLike }from'./actions';typeLikeButtonProps={ postId:string; initialLiked:boolean; initialCount:number;};exportfunctionLikeButton({ 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
);asyncfunctionhandleLike(){// 1. Update UI immediately (optimistic)addOptimisticLike('toggle');addOptimisticCount(optimisticLiked ?-1:1);// 2. Send to serverconst result =awaittoggleLike(postId);// 3. If server succeeded, sync real stateif(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(<buttononClick={handleLike}className="flex items-center gap-2"><span>{optimisticLiked ?'❤️':'🤍'}</span><span>{optimisticCount}</span></button>);}
'use client';import{ useActionState, useOptimistic, useRef }from'react';import{ addTodo }from'./actions';typeTodo={ id:string; text:string; completed:boolean};typeState={ error?:string};exportfunctionTodoList({ 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')asstring;// Optimistic update: add todo immediatelyaddOptimisticTodo({ id:`temp-${Date.now()}`,// temporary ID text, completed:false,});// Reset form immediately formRef.current?.reset();// Call the actual server actionconst result =awaitaddTodo(formData);if(result.error){return{ error: result.error};// useOptimistic will revert because the real todos haven't changed}// Sync real state with server resultsetTodos(result.todos);return{};},{});return(<div><ul>{optimisticTodos.map((todo)=>(<likey={todo.id}className={todo.id.startsWith('temp-')?'opacity-60':''}>{todo.text}{todo.id.startsWith('temp-')&&<span> (saving...)</span>}</li>))}</ul><formref={formRef}action={action}><inputname="text"requiredplaceholder="New todo..."/>{state.error&&<pclassName="text-red-500">{state.error}</p>}<buttontype="submit"disabled={isPending}>{isPending ?'Adding...':'Add'}</button></form></div>);}
// For slow operations, use Suspense + skeleton<Suspensefallback={<CommentSkeleton/>}><CommentListpostId={postId}/></Suspense>// For fast optimistic operations, use inline pending state<LikeButtonisPending={isPending}/>
'use client';import{ useFormStatus }from'react-dom';// This component doesn't need to receive isPending as a propexportfunctionSubmitButton({ children }:{ children:React.ReactNode}){const{ pending }=useFormStatus();return(<buttontype="submit"disabled={pending}aria-busy={pending}>{pending ?'Saving...': children}</button>);}// Usage: works automatically inside any form<formaction={myAction}><inputname="title"/><SubmitButton>Create Post</SubmitButton></form>
asyncfunctionhandleDelete(id:string){// Store the item before optimistic removalconst itemToRestore = items.find(item => item.id=== id);// Optimistic: remove immediatelyaddOptimisticItems(items.filter(item => item.id!== id));try{awaitdeleteItem(id);setItems(prev => prev.filter(item => item.id!== id));}catch{// Explicit rollback: restore the removed itemsetItems(prev =>[...prev, itemToRestore!]); toast.error('Failed to delete. Please try again.');}}