FE Day 12 表單與受控元件
執行需求:CPU 可跑。今天是系列的第十二篇。前幾天我們學會了 useState、useEffect、自訂 Hook 與 Context,這四樣東西加起來已經能處理大多數 React 元件的狀態邏輯。今天要把它們全部用在一個真實的工作場景——「表單」。後端工程師對表單並不陌生:使用者填欄位、前端驗證、送出 HTTP 請求、後端驗證、回傳結果。但 React 表單有兩個特殊觀念:受控元件(controlled component)與非受控元件(uncontrolled component),掌握之後才能正確處理「使用者一邊打字一邊驗證」、「送出時一次驗證全部欄位」、「送出後保留輸入」這些情境。今天會做一個完整的「預約建立表單」,把 React Hook Form 與 Zod 整合起來,順便把昨天學的 Context 用在「使用者必須登入才能送出」這個流程上。整篇範例都在本機 CPU 跑得起來,不依賴雲端服務。
引言
寫後端時,表單處理是「收到 request、表單驗證、寫進資料庫、回 response」這條流程。React 表單的前半段邏輯類似:「使用者輸入欄位、瀏覽器即時更新值、按下送出時驗證、呼叫後端 API」。差別在於「即時更新值」這件事——HTML 表單天生會保留使用者輸入,但 React 的世界裡,我們有兩種方式讀取輸入值:用 React state「控制」欄位值(受控),或讓瀏覽器自己保留欄位值、用 ref「讀出來」(非受控)。
這兩種模式各有優缺。受控元件的優點是「React 永遠知道欄位的值」,可以做即時驗證、條件式 disable 按鈕、依其他欄位動態改變欄位內容。缺點是「每次使用者打字都會 re-render 元件」,對大型表單可能有效能影響。非受控元件的優點是「效能好、寫起來簡單」,缺點是「React 不知道欄位的值」,要讀值時才從 DOM 拉。實務上 90% 的表單用受控元件即可,剩下的 10%(例如檔案上傳、大型表單)才考慮非受控。
今天要回答五個問題:第一,受控元件與非受控元件的差別是什麼?第二,怎麼用 Zod 驗證單一欄位?第三,怎麼在「使用者打字時」與「送出時」分別觸發驗證?第四,React Hook Form 怎麼用、為什麼推薦?第五,怎麼處理「送出失敗但保留使用者輸入」這個情境?我們會做一個「建立預約」的完整表單:欄位包含姓名、Email、日期、時段、備註,按下送出時檢查登入狀態、呼叫 API、把結果顯示在畫面上。整篇閱讀時間約 35 分鐘,動手做大約 40 分鐘。
受控元件:React 永遠知道欄位值
受控元件的核心是兩個 props:value 與 onChange。React 把欄位的值存進 state,使用者每次輸入就觸發 onChange、更新 state,然後 React 把新的 state 傳回 value,欄位就顯示新值。後端工程師可以把它想成「React 永遠是 single source of truth」。
以下是受控輸入框的最基本寫法:
// src/components/NameField.tsx
// 最小的受控輸入框
import { useState } from "react";
export function NameField() {
const [name, setName] = useState("");
return (
<label className="block">
<span className="text-sm font-medium text-slate-700">姓名</span>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1 w-full rounded border border-slate-300 p-2 text-sm"
placeholder="請輸入姓名"
/>
<p className="mt-1 text-xs text-slate-500">目前 {name.length} 字</p>
</label>
);
}
這段程式展示受控元件的三件事:第一,value={name} 把 state 綁到欄位;第二,onChange={(e) => setName(e.target.value)} 把使用者的輸入寫回 state;第三,每次 state 改變,元件 re-render,欄位值同步更新。這條鏈是「單向資料流」的標準模式:state → value,使用者輸入 → onChange → setState → state。
把多個欄位組合成表單:
// src/components/BookingForm.tsx(基礎版)
// 受控表單:所有欄位都住在 React state
import { useState } from "react";
type FormState = {
name: string;
email: string;
date: string;
time: string;
note: string;
};
const empty: FormState = {
name: "",
email: "",
date: "",
time: "",
note: "",
};
export function BookingForm() {
const [form, setForm] = useState<FormState>(empty);
// 通用更新函式:傳入欄位名與新值,更新 form 的某一格
const update = <K extends keyof FormState>(key: K, value: FormState[K]) => {
setForm((prev) => ({ ...prev, [key]: value }));
};
return (
<form className="mx-auto max-w-xl space-y-3 p-4">
<label className="block">
<span className="text-sm font-medium text-slate-700">姓名</span>
<input
type="text"
value={form.name}
onChange={(e) => update("name", e.target.value)}
className="mt-1 w-full rounded border border-slate-300 p-2 text-sm"
/>
</label>
<label className="block">
<span className="text-sm font-medium text-slate-700">Email</span>
<input
type="email"
value={form.email}
onChange={(e) => update("email", e.target.value)}
className="mt-1 w-full rounded border border-slate-300 p-2 text-sm"
/>
</label>
<label className="block">
<span className="text-sm font-medium text-slate-700">日期</span>
<input
type="date"
value={form.date}
onChange={(e) => update("date", e.target.value)}
className="mt-1 w-full rounded border border-slate-300 p-2 text-sm"
/>
</label>
<label className="block">
<span className="text-sm font-medium text-slate-700">時段</span>
<select
value={form.time}
onChange={(e) => update("time", e.target.value)}
className="mt-1 w-full rounded border border-slate-300 p-2 text-sm"
>
<option value="">請選擇</option>
<option value="morning">上午(09:00–12:00)</option>
<option value="afternoon">下午(13:00–17:00)</option>
<option value="evening">晚上(18:00–21:00)</option>
</select>
</label>
<label className="block">
<span className="text-sm font-medium text-slate-700">備註</span>
<textarea
value={form.note}
onChange={(e) => update("note", e.target.value)}
rows={3}
className="mt-1 w-full rounded border border-slate-300 p-2 text-sm"
/>
</label>
</form>
);
}
這個範例把五個欄位(姓名、Email、日期、時段、備註)全部用受控元件管理。注意 update 函式用了泛型 K extends keyof FormState,這樣 TypeScript 能在編譯期檢查「欄位名稱必須是 FormState 的鍵」、「值的型別必須符合該欄位」,不會打錯字。受控元件的缺點在這裡很明顯:每個欄位都要寫 value 與 onChange,五個欄位就重複了五次。當欄位數增加到 20 個時,這種重複會非常囉嗦,這就是後面 React Hook Form 出場的原因。
非受控元件:用 ref 讀欄位值
非受控元件讓瀏覽器保留欄位的值,React 只在需要時才從 DOM 讀出來。最常見的情境是「檔案上傳」(input type="file" 一定要用 ref)或「不需要即時知道值的欄位」。下面是基本寫法:
// src/components/FileUploadField.tsx
// 非受控元件:檔案上傳一定要用 ref
import { useRef } from "react";
export function FileUploadField() {
const fileRef = useRef<HTMLInputElement>(null);
const handleSubmit = () => {
// 送出時才從 DOM 讀取檔案
const file = fileRef.current?.files?.[0];
if (!file) return;
console.log("選擇的檔案:", file.name, file.size);
};
return (
<div className="space-y-2">
<input ref={fileRef} type="file" accept="image/*" className="text-sm" />
<button
type="button"
onClick={handleSubmit}
className="rounded bg-slate-900 px-3 py-1 text-sm text-white"
>
上傳
</button>
</div>
);
}
useRef 回傳一個物件,這個物件的 .current 指向對應的 DOM 節點。React 在元件掛載時把 fileRef.current 設定為 input DOM 節點,從畫面移除時清掉。檔案上傳的 API 必須讀取 DOM 才能拿到 File 物件,所以這是非受控元件的典型情境——檔案無法被 React 「控制」成 state,因為 FileList 是唯讀的瀏覽器物件。
非受控元件在「大量欄位但不需即時驗證」時也有用。例如一個問卷調查有 50 題選擇題,每題都即時 re-render 太浪費,這時可以全部用非受控,送出時再用 FormData 一次讀完。實務上這類情境會直接用 React Hook Form,它的底層用了非受元件機制但提供更友善的 API。
用 Zod 做欄位驗證
Zod 是 TypeScript 生態最常用的 schema 驗證函式庫。它的優點是「型別與驗證一次寫完」:寫完 schema 後,可以從 schema 推導出 TypeScript 型別,也可以在執行期驗證資料。Day 10 我們用過一次,這次在表單上深入。
// src/schemas/booking.ts
// 預約表單的 Zod schema
import { z } from "zod";
export const BookingSchema = z.object({
name: z.string().min(2, "姓名至少 2 個字").max(50, "姓名最多 50 個字"),
email: z.string().email("Email 格式不正確"),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "請選擇有效日期"),
time: z.enum(["morning", "afternoon", "evening"], {
errorMap: () => ({ message: "請選擇時段" }),
}),
note: z.string().max(500, "備註最多 500 字").optional(),
});
export type BookingInput = z.infer<typeof BookingSchema>;
這個 schema 把每個欄位的驗證規則寫在一起。「姓名至少 2 個字」、「Email 格式正確」、「日期是 YYYY-MM-DD」、「時段是三選一」、「備註最多 500 字」這些條件全部用 Zod 描述。執行驗證時,Zod 會回傳一個結果物件,裡面有「是否通過」與「每個欄位的錯誤訊息」。
// src/utils/validate.ts
// 驗證工具:把 Zod 的結果轉成「欄位名 → 錯誤訊息」的物件
import { z } from "zod";
export type FieldErrors = Record<string, string>;
export function validateWithZod<T>(schema: z.ZodType<T>, data: unknown): {
ok: boolean;
data?: T;
errors: FieldErrors;
} {
const result = schema.safeParse(data);
if (result.success) {
return { ok: true, data: result.data, errors: {} };
}
const errors: FieldErrors = {};
for (const issue of result.error.issues) {
const path = issue.path.join(".");
if (!errors[path]) errors[path] = issue.message;
}
return { ok: false, errors };
}
這個工具函式把 Zod 的驗證結果轉成前端表單常用的「以欄位名為鍵」的錯誤物件。Zod 預設回傳的是巢狀的 issues 陣列,這個轉換讓元件可以直接 {errors.email}{errors.email && 顯示錯誤。
React Hook Form:把受控元件的繁瑣交給函式庫
前面示範的純受控表單有兩個問題:第一,每個欄位都要寫 value 與 onChange,欄位多時非常囉嗦;第二,每次打字都 re-render 整個元件,對 20 欄位以上的表單有效能疑慮。React Hook Form(簡稱 RHF)解決了這兩個問題:它用 ref 控制欄位(避免 re-render)、用 register 函式取代 value/onChange、用 handleSubmit 包裝送出邏輯。
// src/components/BookingFormRHF.tsx
// 用 React Hook Form 改寫預約表單
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { BookingSchema, type BookingInput } from "../schemas/booking";
export function BookingFormRHF({ onSubmit }: { onSubmit: (data: BookingInput) => Promise<void> }) {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
} = useForm<BookingInput>({
resolver: zodResolver(BookingSchema),
defaultValues: { name: "", email: "", date: "", time: undefined, note: "" },
});
return (
<form onSubmit={handleSubmit(onSubmit)} className="mx-auto max-w-xl space-y-3 p-4">
<label className="block">
<span className="text-sm font-medium text-slate-700">姓名</span>
<input
{...register("name")}
className="mt-1 w-full rounded border border-slate-300 p-2 text-sm"
/>
{errors.name && <p className="mt-1 text-xs text-red-600">{errors.name.message}</p>}
</label>
<label className="block">
<span className="text-sm font-medium text-slate-700">Email</span>
<input
type="email"
{...register("email")}
className="mt-1 w-full rounded border border-slate-300 p-2 text-sm"
/>
{errors.email && <p className="mt-1 text-xs text-red-600">{errors.email.message}</p>}
</label>
<label className="block">
<span className="text-sm font-medium text-slate-700">日期</span>
<input
type="date"
{...register("date")}
className="mt-1 w-full rounded border border-slate-300 p-2 text-sm"
/>
{errors.date && <p className="mt-1 text-xs text-red-600">{errors.date.message}</p>}
</label>
<label className="block">
<span className="text-sm font-medium text-slate-700">時段</span>
<select
{...register("time")}
className="mt-1 w-full rounded border border-slate-300 p-2 text-sm"
>
<option value="">請選擇</option>
<option value="morning">上午(09:00–12:00)</option>
<option value="afternoon">下午(13:00–17:00)</option>
<option value="evening">晚上(18:00–21:00)</option>
</select>
{errors.time && <p className="mt-1 text-xs text-red-600">{errors.time.message}</p>}
</label>
<label className="block">
<span className="text-sm font-medium text-slate-700">備註</span>
<textarea
{...register("note")}
rows={3}
className="mt-1 w-full rounded border border-slate-300 p-2 text-sm"
/>
{errors.note && <p className="mt-1 text-xs text-red-600">{errors.note.message}</p>}
</label>
<div className="flex gap-2">
<button
type="submit"
disabled={isSubmitting}
className="rounded bg-slate-900 px-4 py-2 text-sm text-white disabled:opacity-50"
>
{isSubmitting ? "送出中…" : "建立預約"}
</button>
<button
type="button"
onClick={() => reset()}
className="rounded bg-slate-200 px-4 py-2 text-sm hover:bg-slate-300"
>
清空
</button>
</div>
</form>
);
}
這段程式把前面的純受控表單換成 React Hook Form 版本。差別有四點:第一,{...register("name")} 一行就把 value、onChange、ref、name 全部接好;第二,resolver: zodResolver(BookingSchema) 讓驗證交給 Zod,errors 直接從 formState 拿;第三,isSubmitting 是內建狀態,送出期間自動為 true;第四,reset() 一行清空所有欄位。
效能上,React Hook Form 用 ref 而非 state 控制欄位,使用者打字時不會觸發整個表單 re-render,只有「訂閱了那個欄位的元件」會更新。對 20 欄位以上的表單,這個差異很明顯。除了效能之外,React Hook Form 還提供 watch(即時觀察欄位值)、setValue(程式化更新欄位)、trigger(手動觸發驗證)、formState.isDirty(使用者是否改過欄位)等豐富 API,這些都是純 useState 寫起來很囉嗦的功能。
整合:表單送出 + 登入檢查 + API 呼叫
把昨天學的 AuthProvider 與今天的表單接起來。完整流程:使用者填好表單 → 檢查是否登入 → 未登入就跳轉到登入頁 → 已登入就呼叫 API → 成功就清空表單、顯示成功訊息;失敗就保留輸入、顯示錯誤訊息。
// src/pages/NewBookingPage.tsx
// 「建立預約」頁面:表單 + 登入檢查 + API 呼叫
import { useState } from "react";
import { useAuth } from "../contexts/AuthContext";
import { BookingFormRHF } from "../components/BookingFormRHF";
import type { BookingInput } from "../schemas/booking";
export function NewBookingPage() {
const { state } = useAuth();
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null);
// 登入檢查:未登入就不渲染表單
if (!state.user) {
return (
<div className="mx-auto max-w-xl p-8">
<p className="text-slate-600">請先登入才能建立預約。</p>
<a href="/login" className="mt-2 inline-block text-blue-600 underline">前往登入</a>
</div>
);
}
const onSubmit = async (data: BookingInput) => {
try {
const res = await fetch("/api/bookings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.message ?? `HTTP ${res.status}`);
}
setResult({ ok: true, message: "預約建立成功!" });
} catch (err) {
// 失敗時保留輸入,只顯示錯誤
setResult({ ok: false, message: err instanceof Error ? err.message : "送出失敗" });
}
};
return (
<main className="min-h-screen bg-slate-50 p-4">
<h1 className="mb-4 text-2xl font-bold">建立預約</h1>
<BookingFormRHF onSubmit={onSubmit} />
{result && (
<p className={`mt-3 text-sm ${result.ok ? "text-green-600" : "text-red-600"}`}>
{result.message}
</p>
)}
</main>
);
}
這個頁面把「受控檢查 → 送出 → 結果顯示」三件事串起來。注意失敗時不呼叫 reset(),這樣使用者的輸入會保留在表單裡,只需修正錯誤欄位即可重送。這是「儲存失敗時保留輸入」這個核心紀律的實踐——絕對不要在失敗時把使用者的辛苦輸入清掉。
常見錯誤與踩雷
第一個踩雷是「忘了設 type="email"」。HTML 的 input type 預設是 text,如果你忘了設 type="email",瀏覽器就不會自動擋掉非 Email 格式,使用者可以輸入「abc」,使用者送出時 Zod 才會跳出「Email 格式不正確」。這雖然能驗證,但 UX 不佳——使用者多按一次送出按鈕才看到錯誤。修法是善用 HTML 原生 type 與瀏覽器內建驗證。
第二個踩雷是「用 value 卻沒設 onChange」。React 會跳出警告「You provided a value prop to a form field without an onChange handler」,並把欄位鎖成唯讀。這在「想設初始值但讓使用者改」時特別常見,例如 input 標籤寫成 value 加初始值但沒給 onChange。修法是用 defaultValue={initial}(非受控)或補上 onChange(受控)。
第三個踩雷是「在 onChange 裡直接呼叫 API」。最常見的寫法是「使用者一邊打字一邊打搜尋 API」,結果每打一個字就送一次請求。這就是昨天 Day 10 教的 useDebounce 的典型應用情境——把受控值先 debounce 再送去打 API,避免伺服器被轟炸。
第四個踩雷是「useState 用 object 卻沒處理不可變更新」。例如 setForm(form); form.name = newName; 這種直接改動原物件的寫法,React 不會偵測到變化、不會 re-render。修法是用展開運算子:setForm({ ...form, name: newName }),或是 React Hook Form 的 setValue。
第五個踩雷是「忘記處理送出中的重複點擊」。使用者可能連續點「送出」按鈕好幾次,導致同一筆預約被建立多次。修法是把按鈕 disabled={isSubmitting},或在 onSubmit 開頭檢查「是否正在送出」。React Hook Form 的 isSubmitting 已經幫你處理這件事,但要記得在按鈕上設 disabled。
效能與實務提醒
受控元件的最大成本是「每次輸入都 re-render」。對小型表單(5 欄位以下)影響微乎其微;對中型表單(10–20 欄位)可以用 React.memo 包子元件降低影響;對大型表單(50 欄位以上)就該用 React Hook Form 或非受控元件。實務上的判斷指標是「打開 React DevTools 的 Profiler,看打字期間哪些元件 re-render 次數最高」。
另一個實務提醒是「表單送出後的清理」。成功的送出應該清空表單(呼叫 reset())、顯示成功訊息、3 秒後導向其他頁面。失敗的送出應該保留輸入、顯示錯誤訊息、讓使用者馬上可以重送。這兩種情境要分清楚,避免「送失敗卻把表單清空」這種 UX 慘案。
最後是「無障礙」考量。表單欄位應該用 label 元素包住輸入框或用 htmlFor 綁定,這樣螢幕閱讀器才能正確朗讀欄位名稱。錯誤訊息應該用 aria-invalid 與 aria-describedby 綁定,這樣輔助科技能朗讀「此欄位有錯誤」。Day 28「無障礙」會再展開這個主題。
另一個團隊協作的實務是「把表單欄位包成可控元件」。如果同一個專案有多個表單(例如「建立預約」、「編輯預約」、「建立使用者」),它們的「姓名」、「Email」、「日期」欄位驗證規則與外觀都很類似。這時可以包成 TextField、EmailField、DateField 等元件,每個元件內部處理自己的受控邏輯與錯誤顯示,外部只要傳 props。這樣有三個好處:第一,欄位樣式統一,不會出現「A 表單的姓名欄跟 B 表單的姓名欄長得不一樣」;第二,驗證邏輯集中管理,未來想改「姓名最少 3 字」只要改一個地方;第三,新表單的開發速度變快,只要組合現有欄位元件即可。
最後是「測試」。表單是元件測試最常見的場景之一。Day 15 會教 Vitest 與 Testing Library 怎麼測表單:模擬使用者輸入(userEvent.type)、觸發驗證(screen.getByText 找錯誤訊息)、驗證 API 呼叫(mock fetch)。良好的表單設計應該讓測試寫起來很直覺——把所有 UI 邏輯放在元件、把資料邏輯放在 Hook、API 呼叫透過 props 注入,這樣測試就能在不真的打 API 的情況下驗證整個流程。
小結
今天我們把 React 表單從受控/非受控元件、Zod 驗證、React Hook Form、整合 Context、處理送出結果一次走完。重點回顧:受控元件用 value + onChange 讓 React 控制欄位值,優點是即時驗證、條件式行為;非受控元件用 ref 讓瀏覽器保留值,適合檔案上傳與大型表單;Zod schema 同時提供型別與執行期驗證,搭配 zodResolver 可直接接到 React Hook Form;失敗時保留輸入、成功時清空並導向,是表單 UX 的基本紀律。我們也用 BookingForm 範例把「受控元件 → Zod → React Hook Form → 整合 Context → 處理送出結果」這條完整的鏈演練了一次,這個模式會在 Day 34「預約流程與表單」與 Day 38「登入流程」繼續使用。明天 Day 13 會進入元件設計原則——組合、抽象、邊界,討論什麼時候該拆元件、什麼時候不該拆、props 怎麼設計、children 怎麼傳,以及容器元件與展示元件的差別,這是「寫出好維護 React 程式」的下一個關鍵能力。我們會用一個「預約卡片」元件當範例,從「全部寫在一個元件」演化到「拆成 Header、Body、Actions 三個子元件」,並且親自感受「過度拆解」帶來的程式碼膨脹。
結語
明天,我們會把焦點從「元件內部邏輯」轉到「元件之間的關係」。Day 13 會探討元件設計的三大原則——單一職責、組合優於繼承、props 的最小介面;並用一個完整的「預約卡片」元件當範例,從「全部寫在一個元件」演化到「拆成 Header / Body / Actions 三個子元件」,讓你體會「什麼時候該拆、什麼時候不該拆」的取捨。記得今天的 BookingFormRHF 先留著,明天會把它內部的「卡片本體」與「動作按鈕」拆成可組合的小元件。
延伸資源
- React 官方〈Forms〉(19.x,2026 年 3 月):
https://react.dev/reference/react-dom/components/form - React Hook Form 官方文件(最新版):
https://react-hook-form.com/ - Zod 官方說明:
https://zod.dev/ - MDN〈HTMLInputElement〉:
https://developer.mozilla.org/zh-TW/docs/Web/API/HTMLInputElement - WebAIM〈Creating Accessible Forms〉:
https://webaim.org/techniques/forms/
留言
張貼留言