系統裡欄位一多,控管起來就豆頁痛。我照之前學到的元件寫法,每個欄位都接上 state:
const [form, setForm] = useState<FileMeta>(initialValues);
<input
value={form.name}
onChange={e => setForm({ ...form, name: e.target.value })}
/>
寫完一測,描述欄位打字的時候,每打一個字,畫面就頓一下。
打開 React DevTools,原因一目了然:form 是整個表單共用的一個物件。描述欄位每打一個字,setForm 就產生一個新物件,整個表單元件重新 render——三十個欄位、每個欄位的錯誤訊息、底下的驗證邏輯,全部重跑一次。
Day 6 我說過:「大型表單怎麼處理,Week 3 會專門講。」今天就是那天。
在 Angular,這種表單我會用 Reactive Forms:
form = new FormGroup({
name: new FormControl('', {
nonNullable: true,
validators: [Validators.required, Validators.maxLength(50)],
}),
category: new FormControl<'doc' | 'image' | 'cad'>('doc', { nonNullable: true }),
effectiveDate: new FormControl<Date | null>(null),
expiryDate: new FormControl<Date | null>(null),
tags: new FormArray<FormControl<string>>([]),
});
<form [formGroup]="form" (ngSubmit)="save()">
<input formControlName="name" />
@if (form.controls.name.errors?.['required']) {
<p>必填</p>
}
</form>
整份表單是一個活在 TypeScript 裡的物件模型。每一個 FormControl 自己記得值、記得有沒有被碰過(touched)、有沒有被改過(dirty)、目前有哪些錯誤(errors)。模板只負責把畫面跟這些 control 綁起來。
打字的時候,變的是那一個 control 自己的值,不會牽動整份表單重算。
這套從來沒想過它的效能。它就是很順。
React 沒有內建表單工具,所以第一版我自己撐。
除了開場那個效能問題,越寫越發現缺的東西不只一樣:
const [form, setForm] = useState(initialValues);
const [errors, setErrors] = useState<Record<string, string>>({});
const [touched, setTouched] = useState<Record<string, boolean>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
touched 要自己記、errors 要自己算、驗證規則散在 onChange 跟 onSubmit 裡、dirty 還沒做、動態增減的標籤欄位還沒做。
Reactive Forms 替我管的那些狀態,現在每一個都變成一個 useState,每一個都要自己接線。
這個模式很熟悉——Day 9 的 useSearch 也是這樣開始的。已經學乖了,這次直接去找套件。
React 社群最主流的表單套件是 React Hook Form。改寫之後:
import { useForm } from 'react-hook-form';
function FileMetaEditor() {
const { register, handleSubmit, formState: { errors } } = useForm<FileMetaForm>({
defaultValues: { name: '', category: 'doc', tags: [] },
});
return (
<form onSubmit={handleSubmit(data => save(data))}>
<input {...register('name', { required: '必填', maxLength: { value: 50, message: '最多 50 字' } })} />
{errors.name && <p>{errors.name.message}</p>}
{/* ……其他欄位 */}
</form>
);
}
再打一次字:不卡了。 打開 DevTools,描述欄位打字的時候,表單元件一次都沒有重新 render。
祕密在 {...register('name')} 這一行。register 回傳一個物件,裡面有 name、onChange、onBlur,還有最關鍵的 ref。展開到 <input> 上之後,React Hook Form 拿到的是真實 DOM 元素的參考。
也就是說,它預設用的是非受控元件——Day 6 提過的那個 defaultValue 模式。值存在 DOM 裡,使用者打字時 DOM 自己更新,React 完全不需要介入。要送出或驗證的時候,再透過 ref 去讀。
這就是它快的原因:打字這件事,根本沒有經過 React 的 render。
而 formState 也很聰明。它用了一個 Proxy,你讀了哪些狀態,它就只在那些狀態變動時才讓元件更新。 上面那段只讀了 errors,所以只有錯誤訊息出現或消失時才會 render;isDirty、touchedFields 沒讀,它們再怎麼變也不會驚動元件。
讀到這裡我愣了一下:讀了什麼就只訂閱什麼——這不就是 Signals 的依賴追蹤?
把兩邊放在一起看,結構其實一模一樣:
表單的狀態,都不放在 render 循環裡。
Reactive Forms 把狀態放進 FormControl 物件,React Hook Form 把狀態放進 DOM 加上它自己的內部 store。兩邊的畫面都只是訂閱需要的那一塊。
在第一版犯的錯,是把表單的每個值都塞進 React 的 state——等於每一次按鍵,都要整份表單重新拍一張快。對越多欄位的表單來說,這張快照太大了。
React Hook Form 自己的驗證寫在 register 的參數裡,欄位一多就很雜。更常見的做法是搭配 Zod,把規則集中成一份 schema:
import { z } from 'zod';
export const fileMetaSchema = z
.object({
name: z.string().min(1, '必填').max(50, '最多 50 字'),
category: z.enum(['doc', 'image', 'cad']),
version: z.string().regex(/^v\d+\.\d+$/, '格式需為 v1.0'),
effectiveDate: z.date(),
expiryDate: z.date().optional(),
tags: z.array(z.object({ value: z.string().min(1) })),
})
.refine(
data => !data.expiryDate || data.expiryDate > data.effectiveDate,
{ message: '失效日必須晚於生效日', path: ['expiryDate'] },
);
export type FileMetaForm = z.infer<typeof fileMetaSchema>;
接到表單上:
import { zodResolver } from '@hookform/resolvers/zod';
const form = useForm<FileMetaForm>({
resolver: zodResolver(fileMetaSchema),
defaultValues: { /* ... */ },
});
register 裡的驗證參數全部拿掉,規則都在 schema 裡。
這裡有三件事讓我很有感:
型別是從規則推出來的。 z.infer 直接產生 FileMetaForm 型別。在 Angular,FormGroup 的型別跟驗證規則是兩件事,改了一邊要記得改另一邊;這裡只有一份,不會對不上。
跨欄位驗證寫在同一個地方。 「失效日必須晚於生效日」這種規則,Angular 要寫一個掛在 FormGroup 上的 validator 函式;Zod 用 refine 寫在 schema 旁邊,path 指定錯誤要顯示在哪個欄位。
同一份 schema,伺服器也能用。 React Router 的 action 是在伺服器上處理表單送出的。把同一份 fileMetaSchema 拿去 action 裡再驗一次,前後端的規則就保證一致。
最後這點要誠實補一句:我常用後端是 Spring Boot,真正的資料驗證還是靠 Java 那邊的 Bean Validation,Zod schema 沒辦法共用過去。所以這份 schema 守的是「前端這一層」,後端那層照樣要寫。
FormArray 跟 useFieldArray標籤欄位可以自由增減,Angular 用 FormArray。React Hook Form 的對應物是 useFieldArray:
const { control, register } = form;
const { fields, append, remove } = useFieldArray({ control, name: 'tags' });
return (
<>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`tags.${index}.value`)} />
<button type="button" onClick={() => remove(index)}>刪除</button>
</div>
))}
<button type="button" onClick={() => append({ value: '' })}>新增標籤</button>
</>
);
兩個細節值得注意。
key 要用 field.id,不能用 index。 useFieldArray 會替每一項產生一個穩定的 id。用 index 當 key,刪掉中間一項時,後面的輸入框內容會錯位——Day 3 講的 track 跟 key,在這裡又出現了。
陣列裡要放物件,不能放純字串。 這也是為什麼 schema 裡的 tags 是 { value: string }[],而不是 string[]。一開始覺得多此一舉,後來才知道是為了讓每一項有地方放那個 id。
剩下幾件事,對照著看就好:
監聽欄位變化。 Angular 用 valueChanges 訂閱;React Hook Form 用 watch 或 useWatch。例如類別選了 CAD 才顯示圖號欄位:
const category = useWatch({ control, name: 'category' });
// ...
{category === 'cad' && <input {...register('drawingNo')} />}
useWatch 只會讓用到它的元件更新,不會牽動整份表單。
送出。 跟 Day 14 的 TanStack Query 接起來:
const update = useMutation({ mutationFn: api.updateFileMeta });
<form onSubmit={handleSubmit(data => update.mutate(data))}>
伺服器回來的錯誤。 例如後端說檔名重複,要顯示在檔名欄位底下:
form.setError('name', { message: '檔名已存在' });
對應 Angular 的 control.setErrors({ duplicate: true })。
| Angular Reactive Forms | React Hook Form + Zod |
|---|---|
FormGroup、FormControl |
useForm |
formControlName="name" |
{...register('name')} |
Validators.required |
Zod schema |
掛在 FormGroup 上的 validator |
refine |
| 手動定義表單型別 | z.infer 從 schema 推出 |
FormArray |
useFieldArray |
valueChanges |
watch / useWatch |
control.errors、touched、dirty |
formState.errors、touchedFields、isDirty |
control.setErrors() |
setError() |
這是我 Week 3 第一次覺得:重建出來的東西,跟 Angular 幾乎長得一樣。
Reactive Forms 跟 React Hook Form 走到了同一個結論:表單的狀態不能放在 render 循環裡。一個放進 FormControl 物件,一個放進 DOM 加上自己的 store,畫面都只訂閱需要的那一塊。
第一版之所以卡,是因為我把「受控元件」當成了 React 的標準答案,每一次按鍵都讓多個欄位一起重新拍照。React Hook Form 的解法,反而是退回非受控,讓 DOM 自己保管值。
Day 6 說受控元件是「把雙向綁定拆開給你看」。今天補上另一半:拆開來看是為了理解,不代表每個欄位都要這樣接。
至於 Zod,它給了我一個 Angular 沒有給過的東西:一份規則,同時是驗證、是型別、還能拿到伺服器上用。