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>

用户提交后,浏览器会执行以下步骤:

  1. 找到表单内具有 name 的成功控件;
  2. 根据控件类型读取值;
  3. 如果存在 requiredtype="email" 等约束,先执行浏览器原生校验;
  4. 将字段编码为请求数据;
  5. methodaction 发起请求;
  6. 根据响应导航或更新文档。

例如,输入框没有 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 替换或重新同步。

并发提交的风险

用户快速连续提交 AB 时,两个请求的完成顺序可能是:

发出 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

关键点有三个:

  1. 浏览器校验发生在 Action 之前;
  2. isPending 覆盖 Action 执行期间,而不是只覆盖 fetch 的某一行;
  3. 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 不同。按钮自身如果有 namevalue,也会进入 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 只读取最近的父表单。

乐观项目不会消失

通常有两种可能:

  1. 失败后错误地把临时项目写入了真实状态;
  2. 组件没有正确处于 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 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。