Hook19.0+

useFormStatus

获取表单内子组件的提交状态

核心概述

在 React 19 之前,如果你想在表单按钮中显示"提交中..."状态, 必须通过 props 将 pending 状态从父组件传递到子组件,这导致:

  • Props 穿透问题:需要在多个层级传递 pending 状态
  • 组件耦合:子组件依赖父组件传递的状态
  • 代码冗余:每个按钮组件都需要接收 pending prop

useFormStatus 是 React 19 引入的 Hook, 专门用于解决表单内子组件获取提交状态的问题。它会自动返回最近父级 <form> 的状态信息, 无需手动传递 props。

💡 心智模型

将 useFormStatus 想象成"表单状态探测器":

  • 自动向上查找:当你在按钮组件调用 useFormStatus 时, 它会自动向上查找最近的 <form> 元素
  • 只读取状态,不修改:你只能查询表单是否正在提交、提交了什么数据, 不能修改表单行为
  • 实时更新:当用户点击提交,表单进入 pending 状态, useFormStatus 会立即返回新的状态,触发子组件重新渲染
  • 隔离作用域:每个 useFormStatus 只能访问自己所在的表单状态, 不会受到其他表单的影响

典型应用场景:

  • 显示按钮的 loading 状态(禁用按钮 + 显示"提交中...")
  • 显示提交进度或加载动画
  • 根据提交状态禁用表单输入(防止重复提交)
  • 访问提交的 FormData 数据(用于日志或预览)

技术规格

类型签名

function useFormStatus(): {
  pending: boolean;
  data: FormData | null;
  method: 'GET' | 'POST' | undefined;
  action: string | ((formData: FormData) => void) | undefined;
}

返回值说明

属性类型说明
pendingboolean表单是否正在提交。true 表示表单提交中(等待 action 完成),false 表示表单空闲
dataFormData | null提交的表单数据。只在提交期间有值,提交完成后恢复为 null
method'GET' | 'POST' | undefined表单的 HTTP 方法。由 <form method="..."> 属性决定
actionstring | function | undefined表单的 action。可以是 URL 字符串或 action 函数

使用限制

  • ⚠️ 必须在 <form> 内部使用:useFormStatus 必须在<form> 的子组件中调用, 否则返回的状态始终为初始值(pending: false, data: null)
  • ⚠️ 只能读取最近父表单的状态:如果嵌套多个表单, useFormStatus 只返回直接父级表单的状态
  • ⚠️ 只能在客户端组件使用:必须在文件顶部添加'use client' 指令
  • ⚠️ 不能在服务端组件使用:useFormStatus 依赖 React 客户端运行时

运行机制

useFormStatus 的实现依赖于 React 的上下文传播机制:

  1. 当你在组件中调用 useFormStatus 时,React 会向上遍历组件树查找最近的<form> 元素
  2. <form> 元素内部维护一个状态上下文(FormStatusContext), 存储 pending、data、method、action 等信息
  3. 当表单提交时,React 会自动更新 FormStatusContext 的值,触发所有使用了 useFormStatus 的子组件重新渲染
  4. 子组件通过 useFormStatus 读取最新的状态信息,无需父组件手动传递 props

实战演练

示例 1: 基础按钮状态

显示按钮的 loading 状态,防止重复提交:

import { useFormStatus } from 'react-dom';

function SubmitButton() {
  // 获取最近父表单的提交状态
  const { pending } = useFormStatus();

  return (
    <button
      type="submit"
      disabled={pending} // 提交期间禁用按钮
      className="px-4 py-2 bg-blue-500 text-white rounded disabled:opacity-50"
    >
      {pending ? '提交中...' : '提交'}
    </button>
  );
}

function ContactForm() {
  const handleSubmit = async (formData: FormData) => {
    // 模拟异步提交
    await new Promise((resolve) => setTimeout(resolve, 2000));
  };

  return (
    <form action={handleSubmit}>
      <input name="email" type="email" placeholder="请输入邮箱" />
      <SubmitButton /> {/* 无需传递 pending prop */}
    </form>
  );
}

示例 2: 生产级表单(完整类型 + 错误处理)

结合 useActionState 和 useFormStatus 实现完整的表单状态管理:

'use client';

import { useFormStatus } from 'react-dom';
import { useActionState } from 'react';

// ============ 类型定义 ============
type FormState = {
  success: boolean;
  error: string | null;
  errors: Record<string, string[]>;
};

// ============ Server Action ============
async function submitForm(
  prevState: FormState,
  formData: FormData
): Promise<FormState> {
  // 模拟网络请求
  await new Promise((resolve) => setTimeout(resolve, 1500));

  const email = formData.get('email') as string;

  // 简单验证
  if (!email || !email.includes('@')) {
    return {
      success: false,
      error: '请输入有效的邮箱地址',
      errors: { email: ['邮箱格式不正确'] },
    };
  }

  // 提交成功
  return {
    success: true,
    error: null,
    errors: {},
  };
}

// ============ 提交按钮组件 ============
function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button
      type="submit"
      disabled={pending}
      className="w-full py-2 px-4 bg-blue-600 text-white font-medium rounded-lg
                 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500
                 disabled:opacity-50 disabled:cursor-not-allowed
                 transition-colors duration-200"
    >
      {pending ? (
        <span className="flex items-center justify-center gap-2">
          <svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
            <circle
              className="opacity-25"
              cx="12"
              cy="12"
              r="10"
              stroke="currentColor"
              strokeWidth="4"
              fill="none"
            />
            <path
              className="opacity-75"
              fill="currentColor"
              d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
            />
          </svg>
          提交中...
        </span>
      ) : (
        '订阅'
      )}
    </button>
  );
}

// ============ 输入框组件 ============
function EmailInput() {
  const { pending } = useFormStatus();

  return (
    <div>
      <label htmlFor="email" className="block text-sm font-medium mb-2">
        邮箱地址
      </label>
      <input
        id="email"
        name="email"
        type="email"
        required
        disabled={pending} // 提交期间禁用输入框
        className="w-full px-3 py-2 border border-gray-300 rounded-lg
                   focus:outline-none focus:ring-2 focus:ring-blue-500
                   disabled:opacity-50 disabled:cursor-not-allowed"
        placeholder="your@email.com"
      />
    </div>
  );
}

// ============ 主表单组件 ============
export default function SubscribeForm() {
  const [state, formAction] = useActionState(submitForm, {
    success: false,
    error: null,
    errors: {},
  });

  if (state.success) {
    return (
      <div className="p-6 bg-green-50 border border-green-200 rounded-lg">
        <h3 className="text-green-800 font-semibold mb-2">订阅成功!</h3>
        <p className="text-green-700">感谢您的订阅。</p>
      </div>
    );
  }

  return (
    <form action={formAction} className="space-y-4 max-w-md">
      <EmailInput />

      {state.error && (
        <div className="p-3 bg-red-50 border border-red-200 rounded-lg">
          <p className="text-red-800 text-sm">{state.error}</p>
        </div>
      )}

      {state.errors.email && (
        <p className="text-red-600 text-sm">{state.errors.email[0]}</p>
      )}

      <SubmitButton />
    </form>
  );
}

示例 3: 访问提交数据

使用 data 属性访问表单提交的数据:

import { useFormStatus } from 'react-dom';

function FormDataLogger() {
  const { pending, data } = useFormStatus();

  // 只在提交期间显示数据
  if (!pending || !data) {
    return null;
  }

  // 读取 FormData 中的字段
  const email = data.get('email');
  const name = data.get('name');

  return (
    <div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
      <p className="text-sm text-blue-800 font-semibold mb-2">正在提交的数据:</p>
      <ul className="text-sm text-blue-700 space-y-1">
        <li>姓名: {name}</li>
        <li>邮箱: {email}</li>
      </ul>
    </div>
  );
}

function FormWithLogger() {
  return (
    <form action={submitAction}>
      <input name="name" placeholder="姓名" />
      <input name="email" placeholder="邮箱" />
      <button type="submit">提交</button>

      <FormDataLogger /> {/* 自动显示提交中的数据 */}
    </form>
  );
}

示例 4: 嵌套表单的状态隔离

每个 useFormStatus 只能访问自己所在的表单状态:

import { useFormStatus } from 'react-dom';

function StatusDisplay({ formName }: { formName: string }) {
  const { pending } = useFormStatus();

  return (
    <div className="text-sm">
      {formName} 状态: {pending ? '提交中...' : '空闲'}
    </div>
  );
}

function FormA() {
  return (
    <form action={actionA} className="p-4 border">
      <h3>表单 A</h3>
      <StatusDisplay formName="表单 A" />
      <button type="submit">提交表单 A</button>
    </form>
  );
}

function FormB() {
  return (
    <form action={actionB} className="p-4 border">
      <h3>表单 B</h3>
      <StatusDisplay formName="表单 B" />
      <button type="submit">提交表单 B</button>
    </form>
  );
}

function NestedForms() {
  return (
    <div>
      <FormA />
      <FormB />
      {/* 两个表单的状态完全独立 */}
    </div>
  );
}

避坑指南

陷阱 1: 在 <form> 外部使用 useFormStatus

问题:如果 useFormStatus 不在任何 <form> 内部, 它将始终返回初始值(pending: false, data: null),无法获取表单状态。

后果:按钮永远不会显示 loading 状态,无法防止重复提交。

❌ 错误代码:

function SubmitButton() {
  const { pending } = useFormStatus(); // 不在任何 form 内部!

  return <button disabled={pending}>提交</button>;
}

function App() {
  return <SubmitButton />; // ❌ 始终返回 { pending: false, data: null }
}

✅ 修正代码:

function SubmitButton() {
  const { pending } = useFormStatus(); // 在 form 内部

  return <button disabled={pending}>提交</button>;
}

function App() {
  return (
    <form action={handleSubmit}>
      <SubmitButton /> {/* ✅ 可以正确获取状态 */}
    </form>
  );
}

心智模型纠正:useFormStatus 是"表单状态探测器", 必须放在"信号源"(form 元素)范围内才能接收到信号。

陷阱 2: 在服务端组件使用 useFormStatus

问题:useFormStatus 是客户端 Hook,只能在添加了'use client' 指令的组件中使用。

后果:运行时报错 "Error: useFormStatus is a client-only hook"。

❌ 错误代码:

// ❌ 缺少 'use client' 指令

import { useFormStatus } from 'react-dom';

function SubmitButton() {
  const { pending } = useFormStatus(); // 运行时报错!
  return <button disabled={pending}>提交</button>;
}

✅ 修正代码:

// ✅ 添加 'use client' 指令
'use client';

import { useFormStatus } from 'react-dom';

function SubmitButton() {
  const { pending } = useFormStatus(); // 正常工作
  return <button disabled={pending}>提交</button>;
}

陷阱 3: 期望 useFormStatus 修改表单行为

问题:useFormStatus 只能读取表单状态, 不能修改表单行为(如阻止提交、修改 action)。

后果:如果需要控制表单提交逻辑,应该在 action 函数或 onSubmit 事件中处理, 而不是依赖 useFormStatus。

❌ 错误代码:

function SubmitButton() {
  const { pending } = useFormStatus();

  const handleClick = () => {
    if (pending) {
      // ❌ 这样无法阻止表单提交!
      return;
    }
  };

  return <button onClick={handleClick}>提交</button>;
}

✅ 修正代码:

function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button
      type="submit"
      disabled={pending} // ✅ 使用 disabled 属性阻止提交
    >
      提交
    </button>
  );
}

// 或者在 action 函数中处理验证逻辑
async function handleSubmit(formData: FormData) {
  const email = formData.get('email');

  if (!email || !email.includes('@')) {
    // ✅ 返回错误信息
    return { success: false, error: '邮箱格式不正确' };
  }

  // 提交逻辑...
}

心智模型纠正:useFormStatus 是"只读显示器", 不是"控制器"。它只能显示表单的状态,不能改变表单的行为。

陷阱 4: 在条件语句或循环中使用 useFormStatus

问题:违反 React Hooks 规则,Hooks 必须在组件顶层调用, 不能在条件语句、循环或嵌套函数中使用。

后果:React 无法正确追踪 Hook 的调用顺序,导致状态混乱或运行时报错。

❌ 错误代码:

function SubmitButton() {
  // ❌ 在条件语句中使用 Hook
  if (someCondition) {
    const { pending } = useFormStatus();
    return <button disabled={pending}>提交</button>;
  }

  return <button>提交</button>;
}

✅ 修正代码:

function SubmitButton() {
  // ✅ 在组件顶层调用 Hook
  const { pending } = useFormStatus();

  // 然后在条件语句中使用返回值
  if (someCondition) {
    return <button disabled={pending}>提交</button>;
  }

  return <button>提交</button>;
}

最佳实践

✅ 推荐模式

1. 创建可复用的按钮组件

将 SubmitButton 组件单独抽离,利用 useFormStatus 自动获取状态, 无需通过 props 传递 pending。

function SubmitButton({ children }: { children: React.ReactNode }) {
  const { pending } = useFormStatus();

  return (
    <button disabled={pending} aria-busy={pending}>
      {pending ? '提交中...' : children}
    </button>
  );
}

// 使用时无需传递任何 props
<form action={handleSubmit}>
  <SubmitButton>提交</SubmitButton>
</form>

2. 结合 useActionState 使用

useActionState 管理表单的完整状态(成功/失败/错误), useFormStatus 管理提交过程中的 UI 状态(pending/loading), 两者配合实现完美的表单体验。

// useActionState: 管理表单结果状态
const [state, formAction] = useActionState(submitForm, initialState);

// useFormStatus: 管理提交过程状态
function SubmitButton() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>提交</button>;
}

3. 使用 pending 禁用所有交互元素

不仅禁用按钮,还应该禁用输入框、下拉框等其他交互元素, 防止用户在提交过程中修改数据。

function FormInput({ name, ...props }: InputProps) {
  const { pending } = useFormStatus();

  return (
    <input
      name={name}
      disabled={pending} // 提交期间禁用
      {...props}
    />
  );
}

function Select({ name, children }: SelectProps) {
  const { pending } = useFormStatus();

  return (
    <select name={name} disabled={pending}>
      {children}
    </select>
  );
}

4. 提供视觉反馈

除了禁用按钮,还应该显示加载动画、改变按钮颜色、添加 aria 属性等, 让用户清楚知道表单正在提交。

function SubmitButton() {
  const { pending } = useFormStatus();

  return (
    <button
      type="submit"
      disabled={pending}
      aria-busy={pending} // 辅助技术识别
      className="flex items-center justify-center gap-2"
    >
      {pending && (
        <LoadingSpinner className="animate-spin h-4 w-4" />
      )}
      {pending ? '提交中...' : '提交'}
    </button>
  );
}

5. 在 TypeScript 中使用类型推断

useFormStatus 的返回值具有完整的类型定义,可以正确推断 pending、data 等属性的类型。

function StatusDisplay() {
  const { pending, data, method, action } = useFormStatus();

  // pending: boolean
  // data: FormData | null
  // method: 'GET' | 'POST' | undefined
  // action: string | function | undefined

  if (pending && data) {
    // TypeScript 知道这里 data 不是 null
    const email = data.get('email');
    // email: string | File | null
  }

  return <div>{pending ? '提交中' : '空闲'}</div>;
}

useFormStatus vs useActionState 对比

特性useFormStatususeActionState
用途获取表单提交过程的状态管理表单的完整生命周期状态
返回值{ pending, data, method, action }[state, formAction, isPending]
使用位置表单内的子组件拥有表单的父组件
管理错误状态❌ 不支持✅ 支持(通过 state.error)
pending 含义表单提交中action 函数执行中
典型用法按钮、输入框组件主表单组件

使用建议

  • 优先使用 useFormStatus 处理子组件的 loading 状态,避免 props 穿透
  • 在父组件使用 useActionState 管理表单的完整状态(成功/失败/错误)
  • 结合两者使用,构建用户体验优秀的表单
  • 记住 useFormStatus 只能读取状态,不能修改表单行为
  • 确保 useFormStatus 在 <form> 内部使用,否则无法获取状态