useFormStatus、useOptimistic 与乐观 UI
第12章:useFormStatus、useOptimistic 与乐观 UI
网络延迟是不可消除的物理事实。乐观 UI 假设操作会成功,立即更新界面,让 99% 的成功路径感觉即时响应,错误路径自动回滚。
本章核心问题:三个表单 Hook 各自解决什么问题?乐观更新的回滚机制如何工作?
读完本章你将理解:
- useFormStatus 让深层嵌套子组件感知父表单提交状态
- useOptimistic 的自动回滚机制——框架保证失败时恢复真实状态
- 三个 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,必须将按钮拆分为独立的子组件。
除了 pending,useFormStatus 还返回 data(提交的 FormData)、method(HTTP 方法)、action(绑定的 action 函数),但 pending 是最常用的字段。
Level 2 · 它是怎么运行的(3-5年经验)
useActionState 深度解析
我们在上一章已经介绍了 useActionState 的基本用法,这里深入理解其内部机制。
const [state, formAction, isPending] = useActionState(action, initialState, permalink?)
三个参数:
action:Server Action 函数,签名为(prevState: S, formData: FormData) => Promise<S>initialState:第一次渲染时的状态初始值permalink(可选):支持渐进增强场景的静态 URL
三个返回值:
state:当前状态,初始为initialState,每次 Action 完成后更新formAction:传给<form action>的增强版函数isPending:Action 执行期间为true
useActionState 与 useFormStatus 的区别在于: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().pending 或 useActionState 的 isPending |
| 表单提交期间禁用输入框 | useActionState 的 isPending(在包含 form 的组件中) |
| 列表项立即反映操作结果 | useOptimistic |
| 显示操作后的错误/成功消息 | useActionState 的 state |
表单提交的理想用户体验是:点击提交后,按钮立即变为禁用状态(防止重复提交),被操作的数据立即反映预期结果(乐观更新),如果失败则显示明确的错误消息并回滚 UI,如果成功则静默完成(或显示短暂的成功提示)。
Level 4 · 边界与陷阱(所有人)
陷阱1:useFormStatus 必须是