React 19 新特性之useFormStatus

Published on
Reading time
9 min read
Likes
Authors

背景说明

在 React 18/19 里,随着 Server Actions 和 Form 组件等功能的引入,React 官方为"表单状态管理"提供了更直接的支持。useFormStatus 是在这个背景下诞生的一个新 HookuseFormStatus – React 中文文档 faviconuseFormStatus – React 中文文档,它专门用于处理以下场景:

  • 表单提交过程中的加载状态展示
  • 防止重复提交
  • 提交过程中的用户反馈
  • 在并发模式下的状态一致性保证
useFormStatus

useFormStatus案例场景

为 “Server Actions” 场景提供更易用的状态管理,简化“局部加载指示”或“禁用按钮”等交互处理,并且这样做可以配合并发特性,避免手动处理竞态(race conditions)

jsxEavan.dev
function SubmitButton() {
  const { pending } = useFormStatus()
  return (
    <button type="submit" disabled={pending}>
      {pending ? '提交中...' : '提交'}
    </button>
  )
}

在以往没有 FormuseFormStatus 的时候,我们常常需要:

  1. 在父组件或某个高层组件中,创建一个“isSubmitting”之类的 state。
  2. 在提交函数中更新它:setIsSubmitting(true),提交后 setIsSubmitting(false)。
  3. 在子组件中通过 props 或 Context 获取这个 isSubmitting,做加载指示或禁用表单等等。

这样做往往会导致:

  • 在各组件之间不断传递这个状态(props drilling)。
  • 多个表单时要维护多个 isSubmitting 状态,还得区分属于哪一个表单。
  • 一旦有并发提交,逻辑就更加复杂。

而现在只要用 Form 把整个表单包裹起来,它就能捕获到这个“表单提交动作”,useFormStatus 在任何子组件中都能直接获取到相关状态,不需要手动提升状态。

竞态问题的解决

React18 faviconReact18

开始引入了并发模式,这意味着我们可以在同一时间内执行多个异步操作,这就可能导致竞态问题。useFormStatus 也能很好的解决这个问题,它会自动处理并发提交的情况,确保只有最后一个提交会被视为“pending”,并且在提交完成后才会重置状态。

场景:用户快速多次点击或触发多次提交

没有并发模式下,通常我们可以这样做:

  1. 用户点击“提交” -> setIsSubmitting(true)。
  2. 等待异步请求完成 -> setIsSubmitting(false)。

如果用户非常快地再次点击了提交按钮(或者由于某些原因触发了二次提交),我们可能用一个“正在提交中就禁止再次提交”的逻辑去规避:

tsxEavan.dev
function MyForm() {
  const [isSubmitting, setIsSubmitting] = useState(false);

  const handleSubmit = async (e) => {
    e.preventDefault();
    if (isSubmitting) return; // 正在提交中就返回

    setIsSubmitting(true);
    try {
      // 假设这里是异步请求
      await saveToServer(...);
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <button disabled={isSubmitting}>提交</button>
    </form>
  );
}

在这种同步渲染的场景下,这段逻辑一般问题不大。

但是在并发模式下,可能出现以下特殊情况:

  1. React 发起一次渲染并把 isSubmitting 设为 true。
  2. 在这次渲染还没“commit”到界面之前,如果某些并发操作让 React 决定“中断、回退、重新开始渲染”,就可能导致组件的状态出现不一致。
  3. 同时,如果请求很快完成或者产生了错误,又触发了另一段更新,就可能出现渲染和状态管理上“谁先谁后”的冲突——有些更新被合并、有些被丢弃,最终导致 isSubmitting 的值和实际提交请求是否完成“错位”,出现竞态问题。

场景说明

你有一个页面上有两个交互:

  1. 一个 “提交” 按钮,用来切换 isSubmitting 的状态。
  2. 一个 “高优先级” 输入框,用来搜索或动态过滤列表。
  • 当点击“提交”时,你会执行 setIsSubmitting(true),并且需要一点计算/网络请求时间去完成,然后再 setIsSubmitting(false)。
  • 在 并发模式 下,React 可能会在“提交”的渲染还未完成时,插入“高优先级”操作(比如用户开始疯狂输入搜索关键词)。React 发现“用户输入”更紧急,就会打断/回滚“提交”相关的渲染,以先完成更高优先级的更新。
提示:在真实项目中,这种竞态问题往往更复杂,比如有多个表单、多个异步请求、各种条件判断等,排查起来很头疼。

有了 useFormStatus 后如何避免这种竞态?

useFormStatus 之所以能够避免手动处理这种竞态问题,原因在于:

  1. React 会自动追踪当前这个 Form 的提交过程:不管在并发模式下有没有打断或重试,React 都知道当前这次提交请求是 属于哪一次 表单提交的。
  2. 当表单提交 pending 状态时,React 内部会设置一个挂起(suspense/pending)的标记;当提交完成、报错或被取消时,这个标记自动更新。
  3. useFormStatus 只是直接读取这个标记并返回给你一个 pending(以及将来的其他状态,例如 error)等布尔值/信息。

这样一来,无论 React 在内部如何处理并发、打断或重试,你拿到的 pending 状态总是“离最新的那个提交最接近的状态”。也就是说,React 官方帮你做了“只关心最新一次提交”的竞态管理工作。

用 useFormStatus 改写上述案例

在使用 Form + Server Actions + useFormStatus 的场景下,示例可以简化为:

tsxEavan.dev
'use client'

import { useFormStatus, Form } from 'react-dom' // 假设 import 路径

export default function MyPage() {
  async function handleAction(formData) {
    // 这里是服务器端的逻辑,比如数据库保存
    await new Promise((r) => setTimeout(r, 1000)) // 模拟网络延迟
    return { success: true }
  }

  return (
    <Form action={handleAction}>
      <MySubmitButton />
    </Form>
  )
}

function MySubmitButton() {
  const { pending } = useFormStatus()

  return (
    <button type="submit" disabled={pending}>
      {pending ? '提交中...' : '提交'}
    </button>
  )
}

在这里:

  1. Form 帮你捕获所有提交行为。
  2. useFormStatus 直接返回是否处于 pending 状态、是否有错误等信息。
  3. 如果用户狂点按钮(甚至在并发模式下出现渲染打断和重试),React 会自动帮我们追踪“哪一次是最新提交”,以及“提交是否还在进行中”。
  4. 我们只要用 disabled={pending} 把按钮灰掉就好了,不会出现需要手动写一堆逻辑来区分是哪一次提交,也不用担心 isSubmitting 和“真实提交状态”不一致的问题。

最佳实践

  1. useFormStatus 只能在 Form 组件内部使用
  2. 建议将状态展示组件(如提交按钮)单独抽离成组件
  3. 配合 React.Suspense 使用可以实现更好的加载体验
  4. 注意区分客户端组件和服务器组件的使用场景