useFormStatus, useOptimistic and Optimistic UI
Why Optimistic UI Matters
Network latency is a physical fact that cannot be engineered away. When a user clicks "Done" to mark a todo item as complete, a UI that waits for the server response before updating — even with only 200ms of latency — feels sluggish and unresponsive. Optimistic UI is the answer: assume the operation will succeed, update the UI immediately, execute the real server operation in the background, and roll back if it fails.
This is not deception. The overwhelming majority of operations succeed. Optimistic UI makes the success path (99% of cases) feel instantaneous, while the error path is still handled correctly. React 19 elevated this pattern from a manual implementation technique into a first-class framework primitive with useOptimistic.
useFormStatus: Reading the Parent Form's Submission State
useFormStatus solves a precise problem: how can a child component inside a form know that "the form is currently submitting"?
// components/SubmitButton.tsx
'use client'
import { useFormStatus } from 'react-dom'
export function SubmitButton({ children }: { children: React.ReactNode }) {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending} aria-disabled={pending}>
{pending ? (
<span className="flex items-center gap-2">
<Spinner className="h-4 w-4 animate-spin" />
Processing...
</span>
) : (
children
)}
</button>
)
}
To use this button, it must be a descendant of the <form>:
// ContactForm.tsx (can be a Server Component)
import { SubmitButton } from './SubmitButton'
import { submitContact } from '@/app/actions'
export default function ContactForm() {
return (
<form action={submitContact}>
<input name="email" type="email" required />
<textarea name="message" required />
<SubmitButton>Send Message</SubmitButton>
</form>
)
}
Two critical constraints on useFormStatus:
Must be a Client Component: it reads the ancestor <form>'s state through React's context mechanism, which operates on the client.
Must be a descendant of <form>, not the component that renders <form> itself: you cannot call useFormStatus in the same component that renders the <form>. The button must be split into a separate child component. This is by design — the hook reads "up" through the tree to find the nearest form context.
Beyond pending, useFormStatus also returns data (the submitted FormData), method (the HTTP method), and action (the bound action function). In practice, pending is used in the vast majority of cases.
useActionState In Depth
Chapter 11 introduced useActionState for basic form state management. Here we examine its mechanics more deeply.
const [state, formAction, isPending] = useActionState(action, initialState, permalink?)
Three parameters:
action: the Server Action, with signature(prevState: S, formData: FormData) => Promise<S>initialState: the state value on first renderpermalink(optional): a static URL for progressive enhancement fallback
Three return values:
state: the current state —initialStateon first render, updated after each Action completesformAction: the enhanced function to pass to<form action>isPending:truewhile the Action is executing
The key distinction between useActionState.isPending and useFormStatus().pending: isPending from useActionState is available in the component that renders the <form>. useFormStatus().pending must be called inside a descendant of the <form>. Both can be used simultaneously when you need to disable elements at different levels of the component tree:
'use client'
import { useActionState } from 'react'
import { SubmitButton } from './SubmitButton' // uses useFormStatus internally
import { createTodo } from '@/app/actions'
type State = { todos: Todo[]; error?: string }
export function TodoForm({ initialTodos }: { initialTodos: Todo[] }) {
const [state, formAction, isPending] = useActionState(createTodo, {
todos: initialTodos,
})
return (
<div>
{state.error && <p className="text-red-500">{state.error}</p>}
<ul>
{state.todos.map(todo => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
<form action={formAction}>
<input
name="title"
disabled={isPending} // from useActionState — available here
placeholder="Add a todo..."
required
/>
<SubmitButton>Add</SubmitButton> {/* useFormStatus reads pending internally */}
</form>
</div>
)
}
useOptimistic: The Core of Optimistic Updates
useOptimistic accepts a "real state" and returns an "optimistic state" plus a function to trigger optimistic updates. While a Server Action is in flight, the UI displays the optimistic state. When the Action completes, React automatically switches back to the real state — which, if the Action succeeded, should now include the optimistically added content.
const [optimisticState, addOptimistic] = useOptimistic(
realState, // the ground truth (from server or useActionState)
updateFn, // (currentState, optimisticValue) => newOptimisticState
)
The updateFn is a pure function that takes the current state and the optimistic value, and returns the predicted new state. It runs synchronously and immediately — React uses this result to update the UI without waiting for the server.
A Complete Optimistic Todo List
Integrating all three hooks into a complete working example:
// app/todos/actions.ts
'use server'
import { prisma } from '@/lib/prisma'
import { revalidateTag } from 'next/cache'
export type Todo = { id: string; title: string; completed: boolean }
export type TodoState = { todos: Todo[]; error?: string }
export async function addTodo(
prevState: TodoState,
formData: FormData
): Promise<TodoState> {
const title = formData.get('title') as string
if (!title?.trim()) {
return { ...prevState, error: 'Todo title cannot be empty' }
}
try {
const todo = await prisma.todo.create({
data: { title: title.trim(), completed: false },
})
revalidateTag('todos')
return {
todos: [...prevState.todos, todo],
error: undefined,
}
} catch {
return { ...prevState, error: 'Failed to add todo. Please try again.' }
}
}
export async function toggleTodo(id: string, completed: boolean): Promise<void> {
await prisma.todo.update({
where: { id },
data: { completed: !completed },
})
revalidateTag('todos')
}
// app/todos/TodoList.tsx
'use client'
import { useActionState, useOptimistic, useTransition } from 'react'
import { addTodo, toggleTodo, type Todo, type TodoState } from './actions'
import { SubmitButton } from '@/components/SubmitButton'
export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
const [state, formAction, isPending] = useActionState(addTodo, {
todos: initialTodos,
})
// optimisticTodos shows the predicted state while addTodo is in flight
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
state.todos,
(currentTodos: Todo[], newTodo: Todo) => [...currentTodos, newTodo]
)
return (
<div className="max-w-md mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">Todo List</h1>
{state.error && (
<div role="alert" className="bg-red-50 text-red-600 p-3 rounded mb-4">
{state.error}
</div>
)}
<ul className="space-y-2 mb-4">
{optimisticTodos.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
<form
action={formAction}
onSubmit={(e) => {
const form = e.currentTarget
const titleInput = form.elements.namedItem('title') as HTMLInputElement
if (!titleInput.value.trim()) return
// Optimistically add the new todo immediately
addOptimisticTodo({
id: `optimistic-${Date.now()}`, // temporary ID until server responds
title: titleInput.value.trim(),
completed: false,
})
// Note: the form action will also fire — this is the server request
}}
>
<div className="flex gap-2">
<input
name="title"
type="text"
placeholder="Add a new todo..."
className="flex-1 border rounded px-3 py-2"
disabled={isPending}
/>
<SubmitButton>Add</SubmitButton>
</div>
</form>
</div>
)
}
The TodoItem Component with Optimistic Toggle
function TodoItem({ todo }: { todo: Todo }) {
const [isPending, startTransition] = useTransition()
// Local optimistic state for the completed toggle
const [optimisticCompleted, setOptimisticCompleted] = useOptimistic(
todo.completed,
(_current: boolean, newValue: boolean) => newValue
)
function handleToggle() {
startTransition(async () => {
// Optimistically flip the checkbox immediately
setOptimisticCompleted(!todo.completed)
// Fire the server action in the background
await toggleTodo(todo.id, todo.completed)
})
}
return (
<li
className={`flex items-center gap-3 p-3 rounded border transition-opacity ${
isPending ? 'opacity-60' : 'opacity-100'
}`}
>
<input
type="checkbox"
checked={optimisticCompleted}
onChange={handleToggle}
disabled={isPending}
className="h-4 w-4 cursor-pointer"
/>
<span
className={
optimisticCompleted ? 'line-through text-gray-400' : 'text-gray-900'
}
>
{todo.title}
</span>
{isPending && (
<span className="text-xs text-gray-400 ml-auto">Saving...</span>
)}
</li>
)
}
Automatic Error Rollback
The rollback behavior of useOptimistic is automatic and requires no manual implementation. When a Server Action settles (whether successfully or with an error), React replaces the optimistic state with the real state:
// The rollback flow on failure:
// 1. User clicks "Add" → addOptimisticTodo immediately shows new item in UI
// 2. Server Action fires → database write fails
// 3. Action returns { ...prevState, error: 'Failed to add todo' }
// 4. React replaces optimisticTodos with the real state.todos (no new item)
// 5. User sees the error message, UI reverts to pre-action state
This means optimistic UI implementors do not need to write rollback logic. The guarantee is structural: useOptimistic state is always overwritten by real state when the transition completes. You only need to ensure the Server Action returns the correct error state on failure.
There is one subtlety: the todo.id used in the optimistic item is a temporary fake ID. After the real server response, the item in state.todos will have the real database ID. React's reconciliation handles this — the optimistic item disappears and the real item (with real ID) appears in its place. If you use the optimistic ID as a React key for child components, you may see a flash of unmounting/mounting. Using a stable key strategy (like keeping both pending and confirmed items in separate lists) can avoid this if needed.
Loading States and Disabled Inputs During Submission
A practical guide to which hook serves which UI concern:
| UI Requirement | Hook to Use |
|---|---|
| Disable submit button, show spinner | useFormStatus().pending in a child component |
| Disable form inputs during submission | useActionState isPending in the form component |
| Immediately reflect action result in list | useOptimistic |
| Show error/success message after action | useActionState state |
| Prevent multiple simultaneous submissions | disabled={isPending} on the button via either hook |
The ideal submission UX flow:
- User clicks submit — button immediately becomes disabled (preventing double-submit)
- Affected data immediately reflects the expected result (optimistic update)
- On failure: clear error message appears, UI reverts to pre-action state
- On success: the optimistic state is quietly confirmed by the real server response
Disabling inputs during pending state serves an important secondary purpose beyond preventing duplicate submissions: it communicates system state to the user. A visually muted, non-interactive form signals "we've received your input and are working on it," which is more informative than a button that simply disappears.
Combining All Three Hooks
In a non-trivial form, all three hooks work at different layers simultaneously:
// Layer 1: useActionState — manages the Action lifecycle and form binding
const [state, formAction, isPending] = useActionState(myAction, initialState)
// Layer 2: useOptimistic — provides instant UI feedback during the Action
const [optimisticItems, addOptimistic] = useOptimistic(state.items, reducer)
// Layer 3: useFormStatus (in SubmitButton child) — provides pending state
// to the button component without prop drilling
Each hook has a distinct responsibility. useActionState is the source of truth for the Action's result and pending status at the form level. useOptimistic creates the predictive UI layer. useFormStatus propagates pending state to components deep in the form tree without requiring explicit prop threading.
Summary
useFormStatus, useOptimistic, and useActionState form a complete toolkit for form interactions in React 19 with Server Actions. Their responsibilities are cleanly separated: useActionState manages the Action's lifecycle and return value; useFormStatus gives deeply nested components access to the form's submission state; useOptimistic eliminates the perception of waiting on the success path. Together, they enable form interactions that feel as immediate as native desktop applications, while maintaining correctness guarantees that pure client-side optimism cannot provide — because the server is always the final arbiter.