Chapter 12

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:

Three return values:

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:

  1. User clicks submit — button immediately becomes disabled (preventing double-submit)
  2. Affected data immediately reflects the expected result (optimistic update)
  3. On failure: clear error message appears, UI reverts to pre-action state
  4. 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.

Rate this chapter
4.6  / 5  (25 ratings)

💬 Comments