React 19 引入了两个专门用于服务端操作表单的 Hook:useFormState 和 useFormStatus。它们共同解决了表单提交的主要 UX 问题——显示加载状态、展示服务端错误以及根据服务端响应更新 UI——而无需为表单本身引入客户端状态管理。
以下是在 Next.js App Router 中使用这两个 Hook 的方法,以及能产生最简洁实现的模式,包括 AI presentation images 生成表单所采用的方案。
这些 Hook 解决的问题
在 React 19 之前,处理 Server Action 表单需要:
- 用于表单提交的 Server Action
- 用于 pending 状态的客户端状态(
useState) - 用于错误展示的客户端状态
-
useTransition或isPending来处理加载状态 新 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)、method 和 action——适用于更高级的模式。
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 中大多数表单处理需求,客户端代码极少、内置渐进增强,并实现了客户端与服务端的清晰分离。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.