React 19 引入了两个专门用于服务端操作表单的 Hook:useFormStateuseFormStatus。它们共同解决了表单提交的主要 UX 问题——显示加载状态、展示服务端错误以及根据服务端响应更新 UI——而无需为表单本身引入客户端状态管理。

以下是在 Next.js App Router 中使用这两个 Hook 的方法,以及能产生最简洁实现的模式,包括 AI presentation images 生成表单所采用的方案。


这些 Hook 解决的问题

在 React 19 之前,处理 Server Action 表单需要:

  1. 用于表单提交的 Server Action
  2. 用于 pending 状态的客户端状态(useState
  3. 用于错误展示的客户端状态
  4. useTransitionisPending 来处理加载状态 新 Hook 显著减少了这些需求。

useFormStatus — 表单内部的 Pending 状态

useFormStatus 让表单内的任意组件都能访问表单的提交状态。其关键特性是:它读取最近的父级 <form>,而不是通过 props 传递。

// components/SubmitButton.jsx
'use client';
import { useFormStatus } from 'react-dom';

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

  return (
    <button
      type="submit"
      disabled={pending}
      className={`px-6 py-2 rounded-full font-medium transition-all
        ${pending 
          ? 'bg-neutral-400 cursor-not-allowed' 
          : 'bg-orange-500 hover:bg-orange-600'
        }`}
    >
      {pending ? 'Submitting...' : children}
    </button>
  );
}

Enter fullscreen mode Exit fullscreen mode

这个 SubmitButton 组件会在父表单提交时自动显示 pending 状态——无需属性透传,也无需手动管理状态。

// Any form that uses this button
export function ContactForm() {
  return (
    <form action={submitContactForm}>
      <input name="email" type="email" required />
      <textarea name="message" required />
      <SubmitButton>Send Message</SubmitButton>
      {/* SubmitButton knows the form is pending automatically */}
    </form>
  );
}

Enter fullscreen mode Exit fullscreen mode

useFormStatus 还提供了 data(正在提交的 FormData)、methodaction——适用于更高级的模式。


useFormState — 客户端获取服务端响应

useFormState 包装一个 Server Action,并让客户端组件能够访问其返回值。这正是展示服务端验证错误和成功状态的方式。

// app/actions/contact.ts
'use server';

type FormState = {
  success: boolean;
  error?: string;
  fieldErrors?: Record<string, string>;
};

export async function submitContactForm(
  prevState: FormState,
  formData: FormData
): Promise<FormState> {
  const email = formData.get('email') as string;
  const message = formData.get('message') as string;

  if (!email || !email.includes('@')) {
    return {
      success: false,
      fieldErrors: { email: 'Please enter a valid email address' }
    };
  }

  if (!message || message.length < 20) {
    return {
      success: false,
      fieldErrors: { message: 'Message must be at least 20 characters' }
    };
  }

  try {
    await sendEmail({ email, message });
    return { success: true };
  } catch {
    return { success: false, error: 'Failed to send. Please try again.' };
  }
}

Enter fullscreen mode Exit fullscreen mode

注意函数签名:Server Action 现在将 prevState 作为第一个参数——useFormState 会在每次提交时传入上一次的状态。

// components/ContactForm.jsx
'use client';
import { useFormState } from 'react-dom';
import { submitContactForm } from '@/app/actions/contact';
import { SubmitButton } from './SubmitButton';

const initialState = { success: false };

export function ContactForm() {
  const [state, formAction] = useFormState(submitContactForm, initialState);

  if (state.success) {
    return (
      <div className="p-6 bg-green-50 rounded-xl text-green-700">
        Message sent successfully. We'll be in touch soon.
      </div>
    );
  }

  return (
    <form action={formAction}>
      <div className="flex flex-col gap-4">
        <div>
          <input
            name="email"
            type="email"
            placeholder="Your email"
            className="w-full p-3 border rounded-lg"
            aria-describedby="email-error"
          />
          {state.fieldErrors?.email && (
            <p id="email-error" className="text-sm text-red-500 mt-1">
              {state.fieldErrors.email}
            </p>
          )}
        </div>

        <div>
          <textarea
            name="message"
            placeholder="Your message"
            className="w-full p-3 border rounded-lg h-32"
            aria-describedby="message-error"
          />
          {state.fieldErrors?.message && (
            <p id="message-error" className="text-sm text-red-500 mt-1">
              {state.fieldErrors.message}
            </p>
          )}
        </div>

        {state.error && (
          <p className="text-sm text-red-500">{state.error}</p>
        )}

        <SubmitButton>Send Message</SubmitButton>
      </div>
    </form>
  );
}

Enter fullscreen mode Exit fullscreen mode

该表单现在可以:展示来自服务端的字段级错误、提交后显示成功状态、自动显示 pending 的提交按钮——所有这些都无需手动使用 useState 来管理 pending 或错误状态。


结合使用这两个 Hook

最佳实践是将两个 Hook 一起使用:

'use client';
import { useFormState } from 'react-dom';
import { useFormStatus } from 'react-dom';

// useFormStatus: used inside the form, shows pending state
function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Saving...' : 'Save'}
    </button>
  );
}

// useFormState: wraps the action, provides state to the component
function EditProfileForm({ user }) {
  const [state, action] = useFormState(updateProfile, { success: false });

  return (
    <form action={action}>
      <input name="name" defaultValue={user.name} />
      {state.fieldErrors?.name && <p>{state.fieldErrors.name}</p>}

      <input name="bio" defaultValue={user.bio} />
      {state.fieldErrors?.bio && <p>{state.fieldErrors.bio}</p>}

      {state.success && <p>Profile updated!</p>}
      {state.error && <p>{state.error}</p>}

      <SubmitButton />
    </form>
  );
}

Enter fullscreen mode Exit fullscreen mode


渐进增强

这种模式的一个重要优势是:以此方式构建的表单无需 JavaScript 即可工作。Server Action 处理提交,服务端返回响应,表单展示更新后的状态——所有这些都不依赖客户端 JavaScript。

启用 JavaScript 时,体验更快(无需页面刷新、提供 pending 状态反馈)。禁用 JavaScript 时,表单仍可正常工作。当使用原生 <form> 元素并将 action 属性指向 Server Action 时,这种渐进增强会自动生效。


何时不使用此模式

复杂的多步骤表单。 当每个步骤依赖于上一步数据时,最好使用客户端状态管理(Zustand 或 React Query)而非 Server Action。

乐观更新。 如果希望在服务端确认前立即展示结果,应使用 useOptimistic 配合 useTransition,而非 useFormState

实时验证。 useFormState 仅在提交时触发。对于用户输入时的字段级验证,仍需客户端验证逻辑。

对于大多数简单表单——联系表单、登录表单、设置表单、个人资料表单——useFormState + useFormStatus 是 Next.js App Router 中最简洁的方案。


无障碍考虑

上述模式包含 aria-describedby,用于将错误消息与对应字段关联。当使用服务端返回的验证错误时,这一点非常重要——屏幕阅读器需要知道错误指向哪个字段。

<div>
  <label htmlFor="email">Email</label>
  <input
    id="email"
    name="email"
    type="email"
    aria-describedby={state.fieldErrors?.email ? "email-error" : undefined}
    aria-invalid={state.fieldErrors?.email ? "true" : undefined}
  />
  {state.fieldErrors?.email && (
    <p id="email-error" role="alert">
      {state.fieldErrors.email}
    </p>
  )}
</div>

Enter fullscreen mode Exit fullscreen mode

错误段落上的 role="alert" 会在错误出现时向屏幕阅读器播报——无需手动管理焦点。


测试

测试使用 useFormState 的组件需要用 act() 包裹表单并模拟提交:

import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { ContactForm } from './ContactForm';

// Mock the server action
jest.mock('@/app/actions/contact', () => ({
  submitContactForm: jest.fn().mockResolvedValue({ 
    success: true 
  })
}));

test('shows success state after submission', async () => {
  render(<ContactForm />);

  fireEvent.change(screen.getByPlaceholderText('Your email'), {
    target: { value: '[email protected]' }
  });

  fireEvent.change(screen.getByPlaceholderText('Your message'), {
    target: { value: 'This is a test message that is long enough' }
  });

  fireEvent.click(screen.getByRole('button', { name: 'Send Message' }));

  await waitFor(() => {
    expect(screen.getByText(/message sent successfully/i)).toBeInTheDocument();
  });
});

Enter fullscreen mode Exit fullscreen mode

该 mock 将 Server Action 替换为已解析的 Promise,这是 useFormState 所期望的行为。


总结

useFormStatus 让表单内的任意组件都能立即获取表单的 pending 状态——无需属性透传,也无需 context。

useFormState 包装 Server Action,使客户端组件能够访问该操作的返回值——非常适合展示服务端验证错误和成功状态。

二者结合可满足 Next.js App Router 中大多数表单处理需求,客户端代码极少、内置渐进增强,并实现了客户端与服务端的清晰分离。