第 12 章

useFormStatus、useOptimistic 与乐观 UI

第12章:useFormStatus、useOptimistic 与乐观 UI

网络延迟是不可消除的物理事实。乐观 UI 假设操作会成功,立即更新界面,让 99% 的成功路径感觉即时响应,错误路径自动回滚。

本章核心问题:三个表单 Hook 各自解决什么问题?乐观更新的回滚机制如何工作?

读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

为什么需要乐观 UI

网络延迟是不可消除的物理事实。当用户点击"完成"按钮标记一个待办事项时,如果界面必须等待服务器响应才更新,即使只有 200ms 的延迟,也会让交互感觉迟钝。乐观 UI(Optimistic UI)的理念是:假设操作会成功,立即更新界面,同时在后台执行真实的服务端操作,如果失败再回滚

这不是欺骗用户——大多数操作确实会成功。乐观 UI 让成功路径(99% 的情况)感觉即时响应,而错误路径依然有正确处理。React 19 通过 useOptimistic 将这个模式内置到了框架中。

useFormStatus:读取父表单的提交状态

useFormStatus 解决了一个非常具体的问题:如何在表单内部的子组件中知道"表单正在提交中"?

// 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" />
          处理中...
        </span>
      ) : (
        children
      )}
    </button>
  )
}

使用这个按钮时,它必须是 <form> 的后代:

// ContactForm.tsx(可以是 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>发送消息</SubmitButton>
    </form>
  )
}

useFormStatus 的两个关键约束:

必须是 Client Component:因为它使用了 React context 机制读取祖先 <form> 的状态,而这个机制在客户端运行。

必须是 <form> 的后代,而非 <form> 本身:这意味着你不能在同一个组件中既渲染 <form> 又调用 useFormStatus,必须将按钮拆分为独立的子组件。

除了 pendinguseFormStatus 还返回 data(提交的 FormData)、method(HTTP 方法)、action(绑定的 action 函数),但 pending 是最常用的字段。

Level 2 · 它是怎么运行的(3-5年经验)

useActionState 深度解析

我们在上一章已经介绍了 useActionState 的基本用法,这里深入理解其内部机制。

const [state, formAction, isPending] = useActionState(action, initialState, permalink?)

三个参数:

三个返回值:

useActionStateuseFormStatus 的区别在于:isPending 来自 useActionState 时,可以在包含 <form> 的那个组件中直接使用;而 useFormStatus 必须在 <form> 的后代中使用。两者可以同时使用,满足不同层级的状态需求。

'use client'

import { useActionState } from 'react'
import { SubmitButton } from './SubmitButton' // 使用 useFormStatus
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} // 来自 useActionState
          placeholder="添加新待办..."
          required
        />
        <SubmitButton>添加</SubmitButton> {/* 内部使用 useFormStatus */}
      </form>
    </div>
  )
}

useOptimistic:乐观更新的核心

useOptimistic 接受一个"真实状态",返回一个"乐观状态"和一个"触发乐观更新"的函数。在 Server Action 执行期间,界面显示乐观状态;Action 完成后,React 自动切换回真实状态(如果 Action 成功,真实状态应该已经包含了我们乐观添加的内容)。

const [optimisticState, addOptimistic] = useOptimistic(
  realState,       // 真实状态(来自 Server 或 useActionState)
  updateFn,        // (currentState, optimisticValue) => newOptimisticState
)

Level 3 · 规范怎么定义的(资深)

完整的乐观待办列表

将所有知识整合为一个完整示例:

// 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: '待办内容不能为空' }
  }

  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: '添加失败,请重试' }
  }
}

export async function toggleTodo(
  prevState: TodoState,
  formData: FormData
): Promise<TodoState> {
  const id = formData.get('id') as string
  const completed = formData.get('completed') === 'true'

  try {
    await prisma.todo.update({
      where: { id },
      data: { completed: !completed },
    })
    revalidateTag('todos')
    return {
      todos: prevState.todos.map(t =>
        t.id === id ? { ...t, completed: !completed } : t
      ),
    }
  } catch {
    return { ...prevState, error: '更新失败' }
  }
}
// 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 在 Action 执行期间显示预期结果
  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">待办列表</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

          // 乐观添加:立即在 UI 中显示新待办
          addOptimisticTodo({
            id: `optimistic-${Date.now()}`, // 临时 ID
            title: titleInput.value.trim(),
            completed: false,
          })
        }}
      >
        <div className="flex gap-2">
          <input
            name="title"
            type="text"
            placeholder="添加新待办..."
            className="flex-1 border rounded px-3 py-2"
            disabled={isPending}
          />
          <SubmitButton>添加</SubmitButton>
        </div>
      </form>
    </div>
  )
}

// TodoItem 使用独立的 useOptimistic 管理切换状态
function TodoItem({ todo }: { todo: Todo }) {
  const [isPending, startTransition] = useTransition()
  const [optimisticCompleted, setOptimisticCompleted] = useOptimistic(
    todo.completed,
    (_: boolean, newValue: boolean) => newValue
  )

  async function handleToggle() {
    const formData = new FormData()
    formData.append('id', todo.id)
    formData.append('completed', String(todo.completed))

    startTransition(async () => {
      setOptimisticCompleted(!todo.completed) // 立即切换
      // 这里需要调用 toggleTodo,但因为不在 form action 中
      // 需要通过 startTransition 包裹以避免布局跳动
      await toggleTodo({ todos: [] }, formData)
    })
  }

  return (
    <li
      className={`flex items-center gap-3 p-3 rounded border ${
        isPending ? 'opacity-70' : ''
      }`}
    >
      <input
        type="checkbox"
        checked={optimisticCompleted}
        onChange={handleToggle}
        className="h-4 w-4"
      />
      <span className={optimisticCompleted ? 'line-through text-gray-400' : ''}>
        {todo.title}
      </span>
      {isPending && <span className="text-xs text-gray-400 ml-auto">保存中...</span>}
    </li>
  )
}

乐观更新的错误回滚

useOptimistic 的回滚是自动的。当 Server Action 返回(无论成功还是失败)时,React 会将乐观状态替换为真实状态。如果操作失败,prevState(原始状态)会被返回,乐观添加的项目自动消失。

// 错误情况下的回滚流程:
// 1. 用户点击"添加",addOptimisticTodo 立即在 UI 中显示新项目
// 2. Server Action 执行,数据库写入失败
// 3. Action 返回 { ...prevState, error: '添加失败' }
// 4. React 用真实的 state.todos(不含新项目)替换 optimisticTodos
// 5. 用户看到错误消息,UI 回到操作前的状态

这意味着乐观 UI 的实现者不需要手动编写回滚逻辑——框架保证了这一点。你只需要确保 Server Action 在失败时返回正确的错误状态,React 会处理剩余的事情。

提交期间的 UI 状态管理

综合使用这三个 Hook 的最佳实践:

场景 使用的 Hook
禁用提交按钮,显示 spinner useFormStatus().pendinguseActionStateisPending
表单提交期间禁用输入框 useActionStateisPending(在包含 form 的组件中)
列表项立即反映操作结果 useOptimistic
显示操作后的错误/成功消息 useActionStatestate

表单提交的理想用户体验是:点击提交后,按钮立即变为禁用状态(防止重复提交),被操作的数据立即反映预期结果(乐观更新),如果失败则显示明确的错误消息并回滚 UI,如果成功则静默完成(或显示短暂的成功提示)。

Level 4 · 边界与陷阱(所有人)

陷阱1:useFormStatus 必须是

的后代组件,不能在渲染 的同一组件中调用——需要将按钮拆分为独立子组件。

陷阱2:useOptimistic 的回滚是自动的——Server Action 返回时,React 用真实状态替换乐观状态,无需手动编写回滚逻辑。

陷阱3:isPending 来自 useActionState 时可在包含 form 的组件中直接使用;useFormStatus 的 pending 必须在 form 后代中使用——两者可以同时使用满足不同层级需求。

小结

useFormStatususeOptimisticuseActionState 共同构成了 React 19 时代处理表单交互的完整工具集。它们之间有明确的分工:useActionState 管理 Action 的生命周期状态和表单绑定;useFormStatus 让深层嵌套的子组件感知表单提交状态;useOptimistic 消除等待感,让成功路径感觉即时响应。三者结合,能够在不牺牲正确性的前提下,将表单交互的用户体验提升到接近本地应用的水准。

本章评分
4.6  / 5  (25 评分)

💬 留言讨论