Section 1:痛點 — 用 useState 寫表單有多痛苦
想像你接到一個需求:「幫我做一個會員註冊表單,要有姓名、Email、密碼、確認密碼、個人簡介。」 聽起來很普通,對吧?但當你真的動手寫,才發現…
5 個欄位 × 3 個 state(值、錯誤、是否 touched)= 15 個 useState。這還沒算 isSubmitting、isLoading、serverError… 這不是你的錯,這是純 React 表單的原罪。
// 5 個欄位 × 3 個 state = 15 個 useState 😱
const [name, setName] = useState('');
const [nameError, setNameError] = useState('');
const [nameTouched, setNameTouched] = useState(false);
const [email, setEmail] = useState('');
const [emailError, setEmailError] = useState('');
const [emailTouched, setEmailTouched] = useState(false);
const [password, setPassword] = useState('');
const [passwordError, setPasswordError] = useState('');
const [passwordTouched, setPasswordTouched] = useState(false);
const [confirmPassword, setConfirmPassword] = useState('');
const [confirmPasswordError, setConfirmPasswordError] = useState('');
const [confirmPasswordTouched, setConfirmPasswordTouched] = useState(false);
const [bio, setBio] = useState('');
const [bioError, setBioError] = useState('');
const [bioTouched, setBioTouched] = useState(false);
// 還需要全域狀態...
const [isSubmitting, setIsSubmitting] = useState(false);
const [serverError, setServerError] = useState('');
// ── 手動驗證函式(每個欄位都要寫)──────────────────────────────
const validateName = (value: string) => {
if (!value) return '姓名必填';
if (value.length < 2) return '姓名至少 2 個字';
return '';
};
const validateEmail = (value: string) => {
if (!value) return 'Email 必填';
if (!value.includes('@')) return 'Email 格式錯誤';
return '';
};
const validatePassword = (value: string) => {
if (!value) return '密碼必填';
if (value.length < 8) return '密碼至少 8 個字元';
if (!/[A-Z]/.test(value)) return '需包含至少一個大寫字母';
if (!/[0-9]/.test(value)) return '需包含至少一個數字';
return '';
};
// ... 繼續為每個欄位寫 validate 函式
// ── handleSubmit 本人(又臭又長)────────────────────────────────
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// 手動觸發所有欄位驗證
const nameErr = validateName(name);
const emailErr = validateEmail(email);
const passwordErr = validatePassword(password);
const confirmPasswordErr =
password !== confirmPassword ? '兩次密碼不一致' : '';
const bioErr = bio.length > 200 ? '簡介不得超過 200 字' : '';
// 手動 set 每個 error state
setNameError(nameErr);
setEmailError(emailErr);
setPasswordError(passwordErr);
setConfirmPasswordError(confirmPasswordErr);
setBioError(bioErr);
// 如果有任何一個錯誤就中止
if (nameErr || emailErr || passwordErr || confirmPasswordErr || bioErr) return;
setIsSubmitting(true);
try {
await registerUser({ name, email, password, bio });
} catch (err) {
setServerError('伺服器錯誤,請稍後再試');
} finally {
setIsSubmitting(false);
}
};
// ── JSX(更長…)────────────────────────────────────────────────
return (
<form onSubmit={handleSubmit}>
<input
value={name}
onChange={(e) => {
setName(e.target.value);
if (nameTouched) setNameError(validateName(e.target.value));
}}
onBlur={() => {
setNameTouched(true);
setNameError(validateName(name));
}}
placeholder="姓名"
/>
{nameTouched && nameError && <p className="text-red-500">{nameError}</p>}
{/* 每個欄位都要重複同樣的模式... */}
</form>
);這樣寫有什麼問題?
- 1.極度冗長:10 個欄位的表單,光 state 宣告就要寫 30+ 行,整個元件輕鬆突破 200 行。
- 2.驗證邏輯分散:驗證規則散落在 validate 函式、onChange、onBlur、handleSubmit 四個地方,改一個規則要改四處。
- 3.效能問題:每次輸入一個字,整個元件 re-render,因為每個 setState 都觸發更新。
- 4.型別不安全:沒有辦法靜態保證送出的資料結構符合後端期待,type mismatch 只能在執行期才發現。
Section 2:解法 — react-hook-form 的核心概念
react-hook-form 的核心思路是:把表單狀態從 React 的 state 裡移出來,改由 DOM 本身管理(Uncontrolled Inputs),只在需要的時候(submit、驗證)才讀取值。這讓 re-render 次數大幅減少。
類比:傳統的 useState 表單像是每次有人在紙上寫字,你都要抄一份備份放在另一個房間。react-hook-form 則是等他寫完、要交出去的時候,你才去讀那張紙。省了很多來回跑的力氣。
安裝套件
npm install react-hook-form zod @hookform/resolvers三個核心 API
import { useForm } from 'react-hook-form';
const {
register, // 把 input 「登記」給 react-hook-form 管理
handleSubmit, // 只有在驗證通過時才呼叫你的 submit 函式
formState: {
errors, // 驗證錯誤物件,{ fieldName: { message: '...' } }
isSubmitting, // 送出中的 loading 狀態(自動管理)
isDirty, // 有沒有被修改過
isValid, // 目前表單是否通過所有驗證
},
watch, // 即時監看欄位值
setValue, // 程式設定欄位值
control, // 給 Controller 用,控制第三方元件
} = useForm();register()
用 ...register("fieldName") 展開到 input 上,自動綁定 ref、onChange、onBlur、name。不需要任何 useState。
handleSubmit()
包裹你的 onSubmit 函式。它會先跑所有驗證,只有全部通過才呼叫你的函式,並自動管理 isSubmitting 狀態。
errors
驗證錯誤物件。用 errors.fieldName?.message 取得對應欄位的錯誤訊息,沒有錯誤時是 undefined。
Section 3:Zod Schema 驗證
react-hook-form 負責「管理狀態」,Zod 負責「定義規則」。兩者分工明確,各司其職。
為什麼用 Zod 而不是直接在 register 裡寫 validate 函式?三個理由:
1. 宣告式
用 schema 描述「資料長什麼樣」,規則集中在一個地方,不是散落在各個 validate 函式裡。
2. 型別推導
用 z.infer<typeof schema> 直接產生 TypeScript 型別,不需要另外手寫 interface,schema 和型別永遠同步。
3. 前後端共用
同一個 schema 可以在前端表單驗證,也可以在 API handler / Server Action 裡驗證傳入資料。寫一次,兩邊都安全。
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
// ── 定義驗證 Schema ───────────────────────────────────────────────
const registerSchema = z
.object({
name: z
.string()
.min(1, '姓名必填')
.min(2, '姓名至少 2 個字')
.max(50, '姓名不得超過 50 個字'),
email: z
.string()
.min(1, 'Email 必填')
.email('請輸入有效的 Email 格式'),
password: z
.string()
.min(1, '密碼必填')
.min(8, '密碼至少 8 個字元')
.regex(/[A-Z]/, '需包含至少一個大寫字母')
.regex(/[0-9]/, '需包含至少一個數字')
.regex(/[^A-Za-z0-9]/, '需包含至少一個特殊符號'),
confirmPassword: z.string().min(1, '請再次輸入密碼'),
bio: z
.string()
.max(200, '個人簡介不得超過 200 字')
.optional(),
})
// ── 跨欄位驗證(.refine)──────────────────────────────────────
.refine((data) => data.password === data.confirmPassword, {
message: '兩次密碼不一致',
path: ['confirmPassword'], // 錯誤會出現在 errors.confirmPassword
});
// ── 自動從 Schema 推導 TypeScript 型別 ──────────────────────────
// 不需要另外寫 interface!schema 改了,型別自動跟著改。
type RegisterFormData = z.infer<typeof registerSchema>;
// 等同於:
// type RegisterFormData = {
// name: string;
// email: string;
// password: string;
// confirmPassword: string;
// bio?: string | undefined;
// }
// ── 把 Zod Schema 接上 react-hook-form ──────────────────────────
const { register, handleSubmit, formState: { errors } } = useForm<RegisterFormData>({
resolver: zodResolver(registerSchema), // 這一行連接兩者
mode: 'onBlur', // onSubmit | onBlur | onChange | onTouched | all
});mode 的差別
onSubmit — 預設值,只在按送出時驗證onBlur — 離開欄位時驗證,體驗較佳onChange — 每次輸入都驗證,最即時但最多 re-renderonTouched — 第一次 blur 後改用 onChange 模式Section 4:完整表單實作
現在把 Section 1 那個「30 個 useState 的怪物」改寫成 react-hook-form 版本。 注意對比行數和可讀性的差異。
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// ── Schema(定義一次,到處複用)──────────────────────────────────
const registerSchema = z
.object({
name: z.string().min(2, '姓名至少 2 個字'),
email: z.string().email('請輸入有效的 Email'),
password: z
.string()
.min(8, '密碼至少 8 個字元')
.regex(/[A-Z]/, '需包含至少一個大寫字母')
.regex(/[0-9]/, '需包含至少一個數字'),
confirmPassword: z.string(),
bio: z.string().max(200).optional(),
})
.refine((data) => data.password === data.confirmPassword, {
message: '兩次密碼不一致',
path: ['confirmPassword'],
});
type RegisterFormData = z.infer<typeof registerSchema>;
// ── 元件本體 ────────────────────────────────────────────────────
export default function RegisterForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<RegisterFormData>({
resolver: zodResolver(registerSchema),
mode: 'onBlur',
});
const onSubmit = async (data: RegisterFormData) => {
// data 已經是完整通過驗證的型別安全資料
await registerUser(data);
// handleSubmit 會自動管理 isSubmitting,不需要 try/finally
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 max-w-md">
{/* 姓名 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">姓名</label>
<input
{...register('name')}
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-rose-500
${errors.name ? 'border-red-500 bg-red-50' : 'border-gray-300'}`}
placeholder="請輸入姓名"
/>
{errors.name && (
<p className="text-red-500 text-xs mt-1">{errors.name.message}</p>
)}
</div>
{/* Email */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input
{...register('email')}
type="email"
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-rose-500
${errors.email ? 'border-red-500 bg-red-50' : 'border-gray-300'}`}
placeholder="your@email.com"
/>
{errors.email && (
<p className="text-red-500 text-xs mt-1">{errors.email.message}</p>
)}
</div>
{/* 密碼 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">密碼</label>
<input
{...register('password')}
type="password"
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-rose-500
${errors.password ? 'border-red-500 bg-red-50' : 'border-gray-300'}`}
placeholder="至少 8 碼,含大寫與數字"
/>
{errors.password && (
<p className="text-red-500 text-xs mt-1">{errors.password.message}</p>
)}
</div>
{/* 確認密碼 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">確認密碼</label>
<input
{...register('confirmPassword')}
type="password"
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-rose-500
${errors.confirmPassword ? 'border-red-500 bg-red-50' : 'border-gray-300'}`}
placeholder="再次輸入密碼"
/>
{errors.confirmPassword && (
<p className="text-red-500 text-xs mt-1">{errors.confirmPassword.message}</p>
)}
</div>
{/* 個人簡介 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">個人簡介(選填)</label>
<textarea
{...register('bio')}
rows={3}
className={`w-full px-3 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-rose-500
${errors.bio ? 'border-red-500 bg-red-50' : 'border-gray-300'}`}
placeholder="簡短介紹自己..."
/>
{errors.bio && (
<p className="text-red-500 text-xs mt-1">{errors.bio.message}</p>
)}
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full bg-rose-600 text-white py-2.5 rounded-lg font-medium
hover:bg-rose-700 disabled:opacity-50 disabled:cursor-not-allowed
transition-colors"
>
{isSubmitting ? '送出中...' : '建立帳號'}
</button>
</form>
);
}useState 版本
- • 15+ 個 useState 宣告
- • 5 個手動 validate 函式
- • handleSubmit 內手動 set 5 個 error
- • onChange / onBlur 各自處理
- • isSubmitting 要自己管理
- 合計約 150–200 行
react-hook-form 版本
- • 0 個 useState(表單狀態)
- • 1 個 Zod schema 集中管理規則
- • handleSubmit 自動驗證攔截
- • mode: 'onBlur' 統一處理時機
- • isSubmitting 自動管理
- 合計約 70–80 行
Section 5:進階 — watch、setValue、Controller
react-hook-form 的基本用法已經讓你減少了大量樣板程式碼。但它還有三個進階 API, 對應三個實際開發中常見的情境。
1. watch — 即時監看欄位值
情境:你想在用戶輸入密碼的同時,即時顯示「密碼強度」指示器(弱/中/強)。 這需要即時讀取密碼欄位的值,但又不想用 useState 引入不必要的 re-render。
const { register, handleSubmit, watch } = useForm<RegisterFormData>();
// watch('password') 會在 password 欄位改變時觸發 re-render
// 傳入第二個參數作為初始值,避免 undefined
const passwordValue = watch('password', '');
// 根據密碼強度計算分數
const getPasswordStrength = (pwd: string): { score: number; label: string; color: string } => {
let score = 0;
if (pwd.length >= 8) score++;
if (/[A-Z]/.test(pwd)) score++;
if (/[0-9]/.test(pwd)) score++;
if (/[^A-Za-z0-9]/.test(pwd)) score++;
if (score <= 1) return { score, label: '弱', color: 'bg-red-500' };
if (score <= 2) return { score, label: '中', color: 'bg-yellow-500' };
if (score <= 3) return { score, label: '強', color: 'bg-green-500' };
return { score, label: '非常強', color: 'bg-emerald-600' };
};
const strength = getPasswordStrength(passwordValue);
// JSX 中顯示強度指示器
return (
<div>
<input {...register('password')} type="password" />
{/* 密碼強度指示器 */}
{passwordValue && (
<div className="mt-2">
<div className="flex gap-1 mb-1">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className={`h-1 flex-1 rounded-full transition-colors ${
i <= strength.score ? strength.color : 'bg-gray-200'
}`}
/>
))}
</div>
<p className="text-xs text-gray-500">密碼強度:{strength.label}</p>
</div>
)}
</div>
);
// 也可以 watch 多個欄位或整個表單
const allValues = watch(); // 監看所有欄位
const [name, email] = watch(['name', 'email']); // 監看指定多個欄位2. setValue — 程式設定欄位值
情境:用郵遞區號 API 自動填入縣市、地址欄位。用戶只需輸入郵遞區號,其他欄位自動完成。 這類「程式驅動的欄位更新」就是 setValue 的用武之地。
const { register, handleSubmit, setValue } = useForm<AddressFormData>();
// 當用戶輸入郵遞區號,自動查詢並填入城市
const handleZipCodeChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const zip = e.target.value;
// 台灣郵遞區號為 3 或 5 碼
if (zip.length === 3 || zip.length === 5) {
try {
const result = await fetchAddressByZip(zip);
// 自動填入多個相關欄位
setValue('city', result.city, { shouldValidate: true }); // 觸發驗證
setValue('district', result.district, { shouldDirty: true }); // 標記為已修改
setValue('fullAddress', result.address);
} catch {
// 查詢失敗,讓用戶手動填
}
}
};
return (
<form>
<input
{...register('zipCode')}
onChange={handleZipCodeChange}
placeholder="郵遞區號"
maxLength={5}
/>
{/* 這些欄位會被 setValue 自動填入 */}
<input {...register('city')} placeholder="縣市" readOnly />
<input {...register('district')} placeholder="鄉鎮市區" readOnly />
<input {...register('fullAddress')} placeholder="詳細地址" />
</form>
);
// setValue 的第三個參數(options):
// shouldValidate: true → 設定後立即觸發驗證
// shouldDirty: true → 標記為「已被修改」(影響 isDirty)
// shouldTouch: true → 標記為「已被觸碰」(影響 isTouched)3. Controller — 整合第三方 UI 元件
情境:你的設計系統用了 Radix UI 的 Select、React DatePicker、或 HeroUI 的 Select 元件。 這些第三方元件不是原生的 input,所以沒辦法用 ...register() 直接接上。 這時候就需要 Controller 作為橋接器。
import { useForm, Controller } from 'react-hook-form';
import { Select, SelectItem } from '@heroui/react'; // HeroUI 的 Select
type CategoryFormData = {
title: string;
category: 'frontend' | 'backend' | 'devops' | 'mobile';
publishDate: Date;
};
export default function ArticleForm() {
const { register, handleSubmit, control, formState: { errors } } = useForm<CategoryFormData>();
return (
<form onSubmit={handleSubmit(console.log)}>
{/* 一般 input 還是用 register */}
<input {...register('title')} placeholder="文章標題" />
{/* ── Controller 包裹第三方元件 ───────────────────── */}
<Controller
name="category" // 欄位名稱
control={control} // 傳入 control
rules={{ required: '請選擇分類' }} // 也可以在這裡寫驗證規則(或用 zodResolver)
render={({ field, fieldState }) => (
<Select
// Controller 提供的 field 物件包含 onChange、onBlur、value、ref
selectedKeys={field.value ? [field.value] : []}
onSelectionChange={(keys) => field.onChange(Array.from(keys)[0])}
onBlur={field.onBlur}
isInvalid={!!fieldState.error}
errorMessage={fieldState.error?.message}
label="文章分類"
>
<SelectItem key="frontend">前端開發</SelectItem>
<SelectItem key="backend">後端開發</SelectItem>
<SelectItem key="devops">DevOps</SelectItem>
<SelectItem key="mobile">行動開發</SelectItem>
</Select>
)}
/>
{/* DatePicker 也是同樣模式 */}
<Controller
name="publishDate"
control={control}
render={({ field }) => (
<DatePicker
selected={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
dateFormat="yyyy/MM/dd"
/>
)}
/>
<button type="submit">發布</button>
</form>
);
}Section 6:Server Action 整合(Next.js 14+)
傳統做法是前端表單送出後,發一個 fetch 到 /api/register, 後端 API route 再處理資料。Next.js 14 的 Server Action 讓你可以省掉這個 API route, 直接從客戶端元件呼叫在伺服器上執行的函式。
更重要的是:Zod schema 可以在前端和 Server Action 兩層都用。 前端 Zod 防呆(使用者操作失誤)、後端 Zod 防攻擊(惡意 API 呼叫)— 兩層驗證,缺一不可。
步驟一:建立 Server Action
// app/actions/register.ts
'use server'; // ← 這個指令讓這個檔案的所有函式在伺服器上執行
import { redirect } from 'next/navigation';
import { registerSchema } from '@/lib/schemas'; // 共用同一個 Zod schema
// 模擬資料庫操作(實際用 Prisma / Drizzle ORM)
import { db } from '@/lib/db';
import { hash } from 'bcryptjs';
// RegisterFormData 型別從 schema 推導,前後端完全一致
type RegisterFormData = {
name: string;
email: string;
password: string;
confirmPassword: string;
bio?: string;
};
export async function registerAction(formData: RegisterFormData) {
// ── 第二層驗證:後端用 Zod 再驗一次 ─────────────────────────
// 就算有人繞過前端直接打 API,這裡也會擋下來
const validated = registerSchema.safeParse(formData);
if (!validated.success) {
// 回傳結構化的錯誤讓前端可以對應到欄位
return {
success: false,
errors: validated.error.flatten().fieldErrors,
};
}
const { name, email, password, bio } = validated.data;
// ── 檢查 email 是否重複 ───────────────────────────────────────
const existing = await db.user.findUnique({ where: { email } });
if (existing) {
return {
success: false,
errors: { email: ['此 Email 已被使用'] },
};
}
// ── 密碼加密後存入資料庫 ──────────────────────────────────────
const hashedPassword = await hash(password, 12);
await db.user.create({
data: { name, email, password: hashedPassword, bio },
});
// ── 成功後重導向(Server-side redirect)──────────────────────
redirect('/dashboard');
}步驟二:在表單元件呼叫 Server Action
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useState } from 'react';
import { registerAction } from '@/app/actions/register';
import { registerSchema } from '@/lib/schemas';
type RegisterFormData = z.infer<typeof registerSchema>;
export default function RegisterForm() {
const [serverError, setServerError] = useState<string | null>(null);
const {
register,
handleSubmit,
setError, // 可以把 Server 回傳的錯誤設回對應欄位
formState: { errors, isSubmitting },
} = useForm<RegisterFormData>({
resolver: zodResolver(registerSchema),
mode: 'onBlur',
});
const onSubmit = async (data: RegisterFormData) => {
setServerError(null);
// 直接呼叫 Server Action,不需要 fetch('/api/register')
const result = await registerAction(data);
// Server Action 回傳的欄位錯誤,設回對應欄位
if (result?.errors) {
Object.entries(result.errors).forEach(([field, messages]) => {
setError(field as keyof RegisterFormData, {
message: messages[0],
});
});
return;
}
// 如果是全域錯誤
if (result && !result.success) {
setServerError('註冊失敗,請稍後再試');
}
// 成功:Server Action 的 redirect() 會自動處理跳轉
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{serverError && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
{serverError}
</div>
)}
{/* ...欄位 JSX 同 Section 4... */}
<button
type="submit"
disabled={isSubmitting}
className="w-full bg-rose-600 text-white py-2.5 rounded-lg"
>
{isSubmitting ? '建立帳號中...' : '建立帳號'}
</button>
</form>
);
}共用 Schema 的最佳實踐
// lib/schemas/register.ts ← 單一來源,前後端共用
import { z } from 'zod';
export const registerSchema = z
.object({
name: z.string().min(2, '姓名至少 2 個字'),
email: z.string().email('請輸入有效的 Email'),
password: z.string().min(8).regex(/[A-Z]/).regex(/[0-9]/),
confirmPassword: z.string(),
bio: z.string().max(200).optional(),
})
.refine((d) => d.password === d.confirmPassword, {
message: '兩次密碼不一致',
path: ['confirmPassword'],
});
export type RegisterFormData = z.infer<typeof registerSchema>;
// 前端:import { registerSchema } from '@/lib/schemas/register'
// 後端:import { registerSchema } from '@/lib/schemas/register'
// 同一份 schema,兩邊都用 → 規則永遠一致兩層驗證:為什麼都必要?
前端 Zod(防呆)
提升使用者體驗 — 即時回饋,不需等待伺服器。但前端驗證可以被繞過(關掉 JavaScript、直接打 API)。
後端 Zod(防攻擊)
安全性保障 — 就算攻擊者繞過前端直接呼叫 Server Action,後端的 safeParse 仍會擋下格式不符的資料。