React 基础体系 · 第 35/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React 表单与 Action:原生提交、状态、乐观更新和错误
React 19 把表单提交从“监听 submit 事件并手动调用 API”扩展为一种更接近 HTML 原生模型的交互方式:<form> 仍然负责收集字段和触发提交,action 决定提交后的处理函数,useActionState 管理 Action 返回的状态,useFormStatus 读取提交中的状态,useOptimistic 则允许界面先显示预计结果。
这里的 Action 指一个由表单提交触发的函数,不是某个统一的 HTTP 客户端库,也不是 Redux 中的 action 对象。React 19 的相关能力主要包括:
<form action={fn}>:让表单直接调用函数;useActionState:保存 Action 的返回值,并暴露提交状态;useFormStatus:在表单内部读取当前提交状态;useOptimistic:在真实结果返回前显示临时状态;- Server Action:由框架把特定 Action 放到服务端执行的能力。
这些能力建立在 HTML 表单的基础上,而不是替代 HTML 表单。
一、先从原生表单的提交模型开始
HTML 表单的最小模型是:
<form action="/todos" method="post">
<input name="text" required />
<button type="submit">添加</button>
</form>
用户提交后,浏览器会执行以下步骤:
- 找到表单内具有
name的成功控件; - 根据控件类型读取值;
- 如果存在
required、type="email"等约束,先执行浏览器原生校验; - 将字段编码为请求数据;
- 按
method和action发起请求; - 根据响应导航或更新文档。
例如,输入框没有 name 时,它通常不会进入提交数据:
<form action="/search" method="get">
<input name="q" />
<input placeholder="这个字段没有 name,不会提交" />
<button type="submit">搜索</button>
</form>
如果用户输入 react,请求大致是:
GET /search?q=react
name 是表单数据协议的一部分,而不是 React 专属属性。使用 React Action 时仍然如此:
<form action={submitTodo}>
<input name="text" />
<button type="submit">添加</button>
</form>
React 会根据表单控件构造 FormData,然后调用:
submitTodo(formData: FormData)
因此,Action 通常不是这样写的:
// 错误思路:这是事件处理器的写法,不是 form action 函数
function submitTodo(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
}
而是这样写:
async function submitTodo(formData: FormData) {
const text = String(formData.get("text") ?? "")
console.log(text)
}
FormData 的读取方式
const value = formData.get("text")
返回值类型是:
FormDataEntryValue | null
它可能是字符串,也可能是 File。因此,服务端或 Action 中不能无条件假定它一定是字符串:
const rawText = formData.get("text")
if (typeof rawText !== "string") {
throw new Error("text 必须是字符串")
}
const text = rawText.trim()
复选框也有一个容易出错的边界:
<input type="checkbox" name="done" value="yes" />
复选框选中时,数据中有:
done=yes
未选中时,done 通常完全没有 done 这个键。因此不能仅靠:
formData.get("done") === "false"
判断未选中状态,应该明确处理缺失值:
const done = formData.get("done") === "yes"
二、React 中的三种“表单处理方式”
React 应用里常见三种模型,它们的边界不同。
1. 原生 URL 提交
export function SearchForm() {
return (
<form action="/search" method="get">
<label>
关键词
<input name="q" />
</label>
<button type="submit">搜索</button>
</form>
)
}
这里的 action 是字符串,浏览器负责发起导航。React 不需要维护输入框状态,也不需要编写提交函数。
这种方式的优点是:
- 不依赖 JavaScript 也能工作;
- 浏览器行为清晰;
- 适合搜索、筛选、分页等导航型操作。
缺点是:
- 提交后通常发生页面导航;
- 不能直接在当前组件内显示异步提交状态;
- 服务端必须提供对应 URL 和响应。
2. 客户端 Action
"use client"
export function TodoForm() {
async function addTodo(formData: FormData) {
const text = String(formData.get("text") ?? "").trim()
if (!text) {
return
}
await fetch("/api/todos", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ text }),
})
}
return (
<form action={addTodo}>
<input name="text" required />
<button type="submit">添加</button>
</form>
)
}
这里的 action 是函数。React 会阻止表单按普通 URL 导航的方式处理,并把 FormData 交给函数。
这段代码中的数据流是:
用户输入
↓
浏览器约束校验
↓
React 收集 FormData
↓
addTodo(formData)
↓
fetch API
↓
服务端修改数据
客户端 Action 本身不会自动把函数变成服务端函数。fetch 仍然是显式的网络请求,API 路由也必须存在。
3. Server Action
Server Action 是由 React 生态中的框架提供的服务端执行机制。React 提供相关底层能力,但“如何标记、如何序列化、如何部署和鉴权”通常由框架决定。
以支持 React Server Components 的框架为例,服务端文件可能是:
// app/todos/actions.ts
"use server"
export type TodoState = {
ok: boolean
message: string
fieldErrors?: {
text?: string
}
}
export async function createTodo(
previousState: TodoState,
formData: FormData,
): Promise<TodoState> {
const rawText = formData.get("text")
if (typeof rawText !== "string") {
return {
ok: false,
message: "提交数据格式不正确",
fieldErrors: {
text: "文本字段必须是字符串",
},
}
}
const text = rawText.trim()
if (text.length === 0) {
return {
ok: false,
message: "请输入待办内容",
fieldErrors: {
text: "待办内容不能为空",
},
}
}
if (text.length > 100) {
return {
ok: false,
message: "待办内容不能超过 100 个字符",
fieldErrors: {
text: "长度超过限制",
},
}
}
// 这里应替换成真实数据库写入。
// await db.todo.create({ data: { text } })
return {
ok: true,
message: `已添加:${text}`,
}
}
客户端组件使用它:
// app/todos/TodoForm.tsx
"use client"
import { useActionState } from "react"
import { SubmitButton } from "./SubmitButton"
import { createTodo, type TodoState } from "./actions"
const initialState: TodoState = {
ok: false,
message: "",
}
export function TodoForm() {
const [state, formAction, isPending] = useActionState(
createTodo,
initialState,
)
return (
<form action={formAction}>
<label>
待办内容
<input name="text" required maxLength={100} />
</label>
{state.fieldErrors?.text && (
<p role="alert">{state.fieldErrors.text}</p>
)}
{state.message && (
<p role={state.ok ? "status" : "alert"}>
{state.message}
</p>
)}
<SubmitButton pending={isPending} />
</form>
)
}
// app/todos/SubmitButton.tsx
"use client"
import { useFormStatus } from "react-dom"
type Props = {
pending?: boolean
}
export function SubmitButton({ pending }: Props) {
const status = useFormStatus()
const disabled = pending || status.pending
return (
<button type="submit" disabled={disabled}>
{disabled ? "提交中…" : "添加"}
</button>
)
}
在这个例子中,createTodo 的函数签名和普通 <form action={fn}> 不同:
// 普通 form action
(formData: FormData) => unknown
// 传给 useActionState 后
(previousState: State, formData: FormData) => State | Promise<State>
useActionState 会在调用 Action 时自动把上一次返回的状态作为第一个参数传入。因此,直接把只接受一个 FormData 参数的函数传给 useActionState,会导致参数位置不匹配。
三、useActionState 管理的到底是什么
useActionState 的基本形式是:
const [state, formAction, isPending] = useActionState(
action,
initialState,
)
可以把它形式化为:
state₀ = initialState
提交第 n 次:
stateₙ₊₁ = action(stateₙ, formDataₙ)
如果 Action 是异步函数,那么在 Promise 完成前:
isPending = true
完成后:
isPending = false
state = Promise 的结果
以第一次提交为例:
初始:
state = { ok: false, message: "" }
isPending = false
用户提交:
state = { ok: false, message: "" }
isPending = true
Action 返回:
state = { ok: true, message: "已添加:学习 React" }
isPending = false
state 不是“表单字段当前值”。它是 Action 的业务结果。这一区分很重要:
- 输入框当前输入的是表单控件状态;
useActionState保存的是上一次 Action 的返回结果;isPending表示当前 Action 是否仍在执行。
Action 状态应返回什么
对于可预期的业务失败,建议返回结构化状态:
type Result =
| {
ok: true
message: string
}
| {
ok: false
message: string
fieldErrors?: Record<string, string>
}
这样组件可以根据结果渲染:
if (!state.ok && state.fieldErrors?.text) {
// 显示字段级错误
}
不要把所有业务失败都写成:
throw new Error("用户名已存在")
业务规则失败是正常分支,不是程序崩溃。返回结构化错误后,表单可以保留页面上下文并显示可操作提示。
但数据库连接失败、程序不变量被破坏、未预期的服务端异常,则不应伪装成普通字段错误。它们应该进入框架的错误边界、日志和监控路径。
四、useFormStatus 为什么必须放在表单内部
useFormStatus 读取的是最近的父级 <form> 的提交状态。它不能在定义同一个表单的组件中直接读取这个表单:
function WrongForm() {
const { pending } = useFormStatus()
return (
<form action={someAction}>
<button disabled={pending}>提交</button>
</form>
)
}
这里的 Hook 调用位置没有处于 <form> 的子树中,所以它读不到该表单的状态。
正确方式是拆出子组件:
function SubmitButton() {
const { pending, data, method } = useFormStatus()
const text = data?.get("text")
return (
<button type="submit" disabled={pending}>
{pending ? "提交中…" : "添加"}
{pending && typeof text === "string" ? `:${text}` : ""}
</button>
)
}
function Form() {
return (
<form action={someAction}>
<input name="text" />
<SubmitButton />
</form>
)
}
useFormStatus 返回的信息包括:
pending:当前表单是否有提交正在执行;data:当前提交的FormData;method:提交方法;action:当前使用的 Action。
useFormStatus 是读取父表单状态的 Hook,不负责创建 Action,也不负责保存最终业务结果。所以常见分工是:
useActionState → 读取 Action 返回的业务状态
useFormStatus → 读取表单当前是否正在提交
如果一个组件既需要业务错误,又需要按钮 pending 状态,可以同时使用两者。
五、原生校验和服务端校验是两层不同机制
下面的表单包含浏览器约束:
<form action={formAction}>
<input
name="email"
type="email"
required
/>
<button type="submit">订阅</button>
</form>
当输入为空或不是合法邮箱时,浏览器可能在调用 Action 之前阻止提交。此时:
Action 不会执行
useActionState 不会获得新的返回值
服务端不会收到这次表单数据
这能改善交互体验,但不能代替服务端验证。原因是客户端约束可以被绕过:
- 用户可以禁用 JavaScript;
- 用户可以直接构造 HTTP 请求;
- 其他客户端可能不使用你的 HTML;
- 恶意请求不会遵守浏览器校验。
因此,正确条件是:
客户端校验 = 及时反馈
服务端校验 = 安全边界
服务端至少需要重新验证:
const rawEmail = formData.get("email")
if (typeof rawEmail !== "string") {
return { ok: false, message: "邮箱格式错误" }
}
const email = rawEmail.trim()
if (!email.includes("@")) {
return { ok: false, message: "请输入有效邮箱" }
}
客户端和服务端可以共享校验模式,但不能因为客户端已经校验过,就跳过服务端验证。
六、受控表单和原生表单字段不是同一个问题
受控输入由 React 状态驱动:
"use client"
import { useState } from "react"
export function ControlledForm() {
const [text, setText] = useState("")
return (
<form
action={() => {
console.log(text)
}}
>
<input
name="text"
value={text}
onChange={(event) => setText(event.target.value)}
/>
<button type="submit">提交</button>
</form>
)
}
非受控输入由 DOM 保存当前值,提交时由 FormData 读取:
export function UncontrolledForm() {
return (
<form
action={(formData) => {
console.log(formData.get("text"))
}}
>
<input name="text" defaultValue="" />
<button type="submit">提交</button>
</form>
)
}
二者都可以和 Action 一起使用,但用途不同:
- 需要每次输入都驱动预览、格式化或跨字段计算时,受控模式更直接;
- 只需要在提交时读取字段时,非受控模式更接近 HTML,也减少状态同步;
FormData只能读取带有正确name的字段;- 受控输入的
value如果被错误地固定为undefined、空字符串或旧状态,可能覆盖用户输入。
表单 Action 并不要求所有输入都必须 useState。事实上,提交协议本身已经由 DOM 和 FormData 提供。
七、表单提交后的字段重置
使用函数 Action 时,React 会在 Action 完成后对非受控表单字段进行重置。这个行为适合“新增后清空输入”的表单,但不适合所有场景。
例如:
<form action={async (formData) => {
await save(formData)
}}>
<input name="title" />
<button type="submit">保存</button>
</form>
如果输入是非受控字段,成功执行后通常会回到初始 DOM 值。受控字段则仍由 React 的 value 决定:
const [title, setTitle] = useState("")
<input
name="title"
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
如果想在成功后清空受控字段,必须显式执行:
async function action(formData: FormData) {
await save(formData)
setTitle("")
}
但如果保存失败,不应清空输入,否则用户会丢失刚才填写的数据。实际行为还会受到框架的 Server Action 集成方式影响,因此“成功后是否清空”应通过实际测试确认,不能把它当作所有表单状态的通用重置机制。
八、乐观更新:先显示预期状态,再等待真实结果
乐观更新 是一种界面策略:
用户操作
↓
立即显示预计成功的结果
↓
异步请求服务端
↓
成功:以服务端结果确认
失败:回滚或显示失败状态
它与普通 pending 状态的区别是:
普通更新:等待服务器成功后再改变界面
乐观更新:先改变界面,服务器失败时再撤销或修正
useOptimistic 的核心形式是:
const [optimisticState, addOptimistic] = useOptimistic(
baseState,
updateFn,
)
可以表示为:
optimisticState = updateFn(baseState, optimisticValue)
其中:
baseState是真实状态;optimisticValue是本次临时操作;updateFn计算“如果操作成功,界面应该长什么样”。
示例:
"use client"
import { useOptimistic, useState } from "react"
type Todo = {
id: string
text: string
}
type OptimisticTodo = Todo & {
pending?: boolean
}
async function saveTodo(text: string): Promise<Todo> {
const response = await fetch("/api/todos", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ text }),
})
if (!response.ok) {
throw new Error("保存失败")
}
return response.json()
}
export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
const [todos, setTodos] = useState(initialTodos)
const [optimisticTodos, addOptimisticTodo] = useOptimistic<
Todo[],
OptimisticTodo
>(
todos,
(currentTodos, temporaryTodo) => [
...currentTodos,
temporaryTodo,
],
)
async function addTodo(formData: FormData) {
const rawText = formData.get("text")
if (typeof rawText !== "string") {
return
}
const text = rawText.trim()
if (!text) {
return
}
const temporaryTodo: OptimisticTodo = {
id: `temporary-${crypto.randomUUID()}`,
text,
pending: true,
}
addOptimisticTodo(temporaryTodo)
try {
const savedTodo = await saveTodo(text)
// 真实状态更新后,optimisticTodos 会以 todos 为基础重新计算。
setTodos((current) => [...current, savedTodo])
} catch {
// 没有修改 todos,因此临时项目会从乐观视图中消失。
// 生产环境还应显示可见的失败提示。
}
}
return (
<>
<form action={addTodo}>
<input name="text" required />
<button type="submit">添加</button>
</form>
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id}>
{todo.text}
{todo.pending && "(提交中)"}
</li>
))}
</ul>
</>
)
}
这段代码的状态变化是:
真实 todos:
[A]
提交“B”后:
真实 todos:[A]
乐观 todos:[A, B(提交中)]
请求成功,服务端返回 B:
真实 todos:[A, B]
乐观 todos:[A, B]
请求失败:
真实 todos:[A]
乐观 todos:[A]
这里有一个关键条件:乐观状态不能成为最终事实。最终事实必须来自服务端返回值、重新获取的数据或框架的缓存刷新。
为什么临时 ID 必须唯一
如果使用文本作为 React 的 key:
<li key={todo.text}>{todo.text}</li>
两个相同文本会产生冲突。乐观项目还可能在真实项目返回前存在,因此应使用客户端生成的临时 ID,并在服务端返回后用真实 ID 替换或重新同步。
并发提交的风险
用户快速连续提交 A 和 B 时,两个请求的完成顺序可能是:
发出 A
发出 B
先完成 B
后完成 A
如果客户端用“最后一次响应覆盖整个列表”,可能把较新的状态覆盖掉。可靠做法通常包括:
- 让服务端返回单条已保存记录,而不是依赖客户端推断;
- 使用服务端生成的 ID 和时间版本;
- 通过重新获取列表确认最终顺序;
- 对编辑操作携带版本号,检测并发冲突;
- 对删除、撤销等操作设计明确的幂等语义。
useOptimistic 解决的是显示层的临时状态,不会自动解决并发写入、重复请求或服务端冲突。
九、错误不是一种状态,而是多条故障路径
表单错误至少可以分为五类。
1. 浏览器原生校验失败
例如:
<input name="email" type="email" required />
表现:
- 浏览器显示校验提示;
- Action 通常不会执行;
- 业务状态不会更新。
诊断方式是检查 HTML 约束和浏览器控制台,而不是先查 API 日志。
2. 业务校验失败
例如用户名已存在、库存不足、标题重复。这类失败是可预期的,应返回结构化结果:
return {
ok: false,
message: "保存失败",
fieldErrors: {
title: "标题已存在",
},
}
组件根据 state 渲染错误,同时保留用户输入。
3. 未预期异常
例如数据库连接中断、代码访问了不存在的对象。可以抛出异常:
throw new Error("数据库不可用")
但生产环境不应直接把完整异常堆栈展示给用户。框架通常会把这类异常交给错误边界或服务端错误处理机制。用户看到的是通用错误,服务端日志记录详细原因。
4. 鉴权和授权失败
Server Action 不能因为“只有页面上的按钮能调用”就认为安全。客户端提交的数据和调用入口都可以被伪造。
服务端 Action 中应验证当前用户:
export async function createTodo(
previousState: TodoState,
formData: FormData,
): Promise<TodoState> {
const user = await getCurrentUser()
if (!user) {
return {
ok: false,
message: "请先登录",
}
}
// 还要检查 user 是否有创建待办的权限
// ...
}
身份验证回答“你是谁”,授权回答“你能否执行这个操作”。二者都不能只在客户端完成。
5. 网络或传输失败
客户端 Action 通过 fetch 调用 API 时,网络断开、请求超时或服务端返回 500,都需要单独处理:
async function action(formData: FormData) {
try {
const response = await fetch("/api/todos", {
method: "POST",
body: formData,
})
if (!response.ok) {
return {
ok: false,
message: "服务暂时不可用,请稍后重试",
}
}
return {
ok: true,
message: "保存成功",
}
} catch {
return {
ok: false,
message: "网络连接失败",
}
}
}
如果使用 Server Action,网络层的具体封装由框架负责,但业务代码仍然需要区分预期失败和未预期异常。
十、错误与乐观更新必须配合回滚策略
一个常见错误是只做乐观插入,不处理失败:
addOptimisticTodo(todo)
await saveTodo(todo.text)
如果 saveTodo 抛错,界面可能一直显示一个实际上不存在的项目,除非 useOptimistic 的基础状态没有更新并且组件仍处于对应的 Action 生命周期中。
更完整的流程应明确写出:
临时添加
↓
请求发送
├─ 成功 → 用服务端数据更新真实状态
└─ 失败 → 移除临时数据,并显示可重试错误
如果失败后允许重试,还需要决定重试语义:
- 重试是否使用相同的幂等键;
- 服务端是否可能已经写入但响应丢失;
- 是否会因为重复提交创建两条记录。
对于创建操作,客户端可以生成幂等键并发送给服务端:
const requestId = crypto.randomUUID()
await fetch("/api/todos", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": requestId,
},
body: JSON.stringify({ text }),
})
服务端需要保存和检查这个键,单独生成请求 ID 并不能自动实现幂等。
十一、一个完整的 Action 生命周期
以 useActionState 为例,完整时序可以表示为:
sequenceDiagram
participant U as 用户
participant F as Form
participant R as React Action
participant S as 服务端
participant V as 视图状态
U->>F: 填写字段并提交
F->>F: 浏览器原生校验
alt 校验失败
F-->>U: 显示浏览器提示
else 校验通过
F->>R: 传入 FormData
R->>V: isPending = true
R->>S: 执行业务逻辑或请求
alt 业务失败
S-->>R: 返回结构化错误
R->>V: state = 错误结果
else 成功
S-->>R: 返回成功结果
R->>V: state = 成功结果
else 未预期异常
S-->>R: 抛出异常
R->>V: 交给错误边界或框架错误处理
end
R->>V: isPending = false
V-->>U: 更新表单反馈
end
关键点有三个:
- 浏览器校验发生在 Action 之前;
isPending覆盖 Action 执行期间,而不是只覆盖fetch的某一行;- Action 返回的状态和异常不是同一个通道。
十二、formAction 与按钮级 Action
React 支持在不同提交按钮上指定不同的 Action:
function Editor() {
async function saveDraft(formData: FormData) {
// 保存草稿
}
async function publish(formData: FormData) {
// 发布内容
}
return (
<form>
<input name="title" />
<textarea name="content" />
<button type="submit" formAction={saveDraft}>
保存草稿
</button>
<button type="submit" formAction={publish}>
发布
</button>
</form>
)
}
点击不同按钮时,提交的数据仍然来自同一个表单,但使用的 Action 不同。按钮自身如果有 name 和 value,也会进入 FormData:
<button
type="submit"
name="intent"
value="publish"
formAction={saveDraft}
>
发布
</button>
Action 中可以读取:
const intent = formData.get("intent")
不过,若不同操作的权限、校验和副作用差异很大,拆成不同的服务端 Action 通常比在一个函数里堆叠大量 if 更容易审计。
十三、渐进增强与客户端边界
渐进增强 的意思是:基础功能先依赖浏览器和服务端 HTML 表单成立,再利用 JavaScript 改善体验。
原生 URL 表单天然具备这点:
<form action="/search" method="get">
<input name="q" />
<button type="submit">搜索</button>
</form>
函数 Action 是否能在 JavaScript 未加载时仍然工作,取决于具体框架对 Server Action 的支持方式。不能仅因为代码写成了 <form action={fn}>,就断言所有 React 应用都能在无 JavaScript 下提交。
客户端与服务端边界也必须明确:
| 写法 | 执行位置 | 是否需要 API 或框架服务端支持 |
|---|---|---|
action="/search" |
浏览器发请求 | 需要 URL 服务 |
action={clientFunction} |
浏览器 | 函数通常需要客户端组件 |
action={serverAction} |
框架服务端 | 需要 Server Action 支持 |
fetch("/api/...") |
浏览器发请求 | 需要 API 端点 |
"use client" 和 "use server" 不是可随意交换的注释:
"use client"通常表示模块需要在浏览器中运行,可以使用客户端 Hook;"use server"通常由支持 Server Components 的框架识别,用于声明服务端执行边界;- 具体文件组织、导入规则、可序列化参数和部署行为由框架规定。
不要把数据库连接、私钥或服务端凭据放进客户端 Action 模块。即使 TypeScript 编译通过,打包边界也可能导致敏感代码泄露或直接无法运行。
十四、常见失败表现与诊断顺序
Action 没有收到字段
检查:
<input name="text" />
而不是只有:
<input id="text" />
id 用于标签关联和 DOM 定位,name 决定是否进入表单数据。
useActionState 参数错位
错误:
async function action(formData: FormData) {
// 传给 useActionState 后,formData 实际会收到 previousState
}
正确:
async function action(previousState: State, formData: FormData) {
// ...
}
useFormStatus 始终是 false
检查使用它的组件是否真的位于 <form> 子树内。如果 Hook 和 <form> 是同一个组件中的兄弟逻辑,Hook 不能读取该表单。
提交按钮一直禁用
检查是否把永久条件写成了:
<button disabled={true}>提交</button>
或者 pending 是否来自错误的表单。也要注意多个表单同时存在时,useFormStatus 只读取最近的父表单。
乐观项目不会消失
通常有两种可能:
- 失败后错误地把临时项目写入了真实状态;
- 组件没有正确处于 Action 生命周期,导致基础状态和乐观状态的关系不符合预期。
调试时同时打印:
真实状态 baseState
乐观状态 optimisticState
Action 是否 pending
服务端响应状态
不要只观察界面列表。
服务端返回成功但页面数据旧
Action 成功只说明本次操作完成,不代表所有已经渲染的数据自动刷新。使用 Server Action 时,需要按照框架的数据缓存和重新验证机制刷新相关数据;客户端数据层则需要更新缓存或重新请求。
十五、选择哪种模型
可以用提交结果的性质来选择:
提交是否需要页面导航?
├─ 是:原生 URL form
└─ 否:
是否有框架提供服务端函数执行?
├─ 是:Server Action + useActionState
└─ 否:客户端 Action + fetch API
再判断输入状态:
是否需要实时驱动其他 UI?
├─ 是:考虑受控输入
└─ 否:优先考虑原生字段 + FormData
最后判断反馈策略:
操作失败代价是否高,或结果是否不可逆?
├─ 是:通常等待服务端确认后更新
└─ 否:可以使用 useOptimistic,但必须设计失败回滚
表单 Action 的核心不是“把所有提交函数换成一个新 Hook”,而是重新明确几类状态的职责:
DOM/FormData → 用户提交了什么
Action → 如何处理提交
useActionState → Action 返回了什么业务结果
useFormStatus → Action 当前是否仍在执行
useOptimistic → 在真实结果前暂时显示什么
服务端校验与权限 → 这次操作是否真的允许发生
当这些边界保持清楚时,原生提交、客户端交互、服务端变更、乐观更新和错误处理可以组合使用,而不会把表单变成一组互相覆盖的状态变量。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React 身份与 useId:Key、DOM ID、水合和列表稳定性
- 下一篇:React Hook Form:字段注册、Schema、动态表单和性能
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论