React 19 針對伺服器動作表單引入了兩個專用 hooks:useFormState 與 useFormStatus。它們共同解決了表單提交的主要 UX 問題——顯示載入狀態、呈現伺服器錯誤,以及根據伺服器回應更新 UI——而無需為表單本身管理客戶端狀態。
以下說明這兩個 hooks 在 Next.js App Router 中的運作方式,以及能產生最乾淨實作的模式,包含用於 AI 簡報圖像 生成表單的做法。
這些 Hooks 解決的問題
在 React 19 之前,處理 Server Action 表單需要:
- 用於表單提交的 Server Action
- 用於 pending 狀態的客戶端狀態 (
useState) - 用於錯誤顯示的客戶端狀態
-
useTransition或isPending來處理載入狀態 新的 hooks 大幅簡化了這些步驟。
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 狀態——無需 prop drilling,也不需要手動管理狀態。
// 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 或錯誤狀態。
結合兩個 Hooks
最佳做法是同時使用這兩個 hooks:
'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> 元素搭配指向 Server Actions 的 action 屬性時,這種漸進式增強會自動生效。
何時不應使用此模式
複雜的多步驟表單。 當每個步驟都依賴前一步驟的資料時,建議改用客戶端狀態管理(Zustand 或 React Query),而非 Server Actions。
樂觀更新。 如果你希望在伺服器確認前立即顯示結果,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 狀態——無需 prop drilling,也不需要 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.