Tailwind, Radix & shadcn/ui · Part 11 — Forms with react-hook-form + zod
The hardest UI, done right: one zod schema as the single source of truth, react-hook-form for performant state, and shadcn Form components for accessible, type-safe, validated forms. With a live form lab.
Form là nơi UI khó nhất: cùng lúc bạn phải lo state của hàng chục input, validate theo luật phức tạp, hiện lỗi đúng chỗ, nối dây khả năng tiếp cận (a11y) cho screen reader, giữ hiệu năng khi gõ phím, và đảm bảo type an toàn từ input tới lúc submit. Tự làm tay thì mỗi phần là một bãi mìn. Stack hiện đại chia để trị, mỗi thư viện lo đúng một việc nó giỏi nhất:
react-hook-form(RHF) — quản state form bằng uncontrolled (ref), nên gõ một field không re-render cả form. Đây là phần “động cơ”.zod— mô tả hình dạng + luật của dữ liệu bằng một schema. Schema này vừa validate lúc chạy, vừa sinh ra type TypeScript — một nguồn sự thật duy nhất.- shadcn
Form— lớp keo nối RHF với JSX, tự động gắnid/htmlFor/aria-*và render lỗi, để mọi field tiếp cận được mà bạn không viết tay.
Lifecycle của một form production
Đọc form theo vòng đời, không đọc như một đống input:
| Giai đoạn | Công cụ chịu trách nhiệm | Câu hỏi cần đúng |
|---|---|---|
| Khởi tạo | defaultValues + schema | Giá trị rỗng có hợp lệ với type không? |
| Nhập liệu | RHF register/controller | Gõ có re-render quá nhiều không? |
| Validate client | zod + zodResolver | Lỗi có đúng field và đúng thời điểm không? |
| Validate server | action/API + setError | Email trùng, permission, rule backend hiển thị ra sao? |
| Submit pending | formState.isSubmitting | Có khóa nút, chống double submit không? |
| Thành công | reset, toast, redirect | Form trở về trạng thái nào? |
| Khả năng tiếp cận | shadcn FormField/FormMessage | label, description, error có nối ARIA đúng không? |
Form tốt không chỉ “validate được”. Nó phải cho người dùng biết đang sai gì, không mất dữ liệu khi lỗi server, không submit hai lần, và screen reader đọc được cùng thông tin mà mắt thường thấy.
Bài này mổ xẻ từng mảnh tới mức bạn không cần mở docs nữa. Thử trước — gõ giá trị sai và xem lỗi field cùng form state phản ứng:
1. Vì sao là bộ ba này — controlled vs uncontrolled
Để hiểu RHF giải quyết gì, phải hiểu hai cách React quản input.
Controlled — mỗi input là một mảnh useState. Mỗi lần gõ phím gọi setState, React re-render lại component để đồng bộ value. Một form 12 field = 12 state, và mỗi phím gõ render lại toàn bộ form (kèm mọi lỗi, mọi validate). Form nhỏ thì không sao, form lớn thì giật.
// ❌ Controlled thủ công — mỗi phím gõ re-render cả form
const [email, setEmail] = useState('');
const [name, setName] = useState('');
// …nhân lên 12 lần, cộng validate chạy mỗi keystroke
<input value={email} onChange={(e) => setEmail(e.target.value)} />
Uncontrolled — input tự giữ giá trị của nó trong DOM (như HTML thuần). Bạn chỉ đọc giá trị qua ref khi cần (lúc submit, lúc blur). Gõ phím không chạm tới React → không re-render. Đây chính là cách RHF làm: nó đăng ký mỗi input qua một ref, theo dõi giá trị ngoài vòng render của React.
Controlled (useState) Uncontrolled (react-hook-form)
───────────────────── ──────────────────────────────
gõ phím gõ phím
→ setState → DOM tự cập nhật value
→ React re-render component (React KHÔNG render lại)
→ mọi field render lại RHF đọc value qua ref khi:
→ validate chạy lại • blur (mode: 'onBlur')
• submit (handleSubmit)
Tốn render, dễ giật Re-render tối thiểu, mượt
Chốt: RHF nhanh vì nó tránh re-render, không phải vì tối ưu re-render. Component form của bạn render gần như một lần; chỉ những phần subscribe vào lỗi (như
<FormMessage />) mới render lại khi lỗi đổi.
Ba mảnh ghép lại theo sơ đồ trách nhiệm:
┌────────────── zod schema ──────────────┐
│ luật validate (runtime) │
│ + type (compile-time, qua z.infer) │
└───────────────┬─────────────────────────┘
│ zodResolver
┌───────────────▼─────────────────────────┐
│ react-hook-form (useForm) │
│ state qua ref · formState · handleSubmit│
└───────────────┬─────────────────────────┘
│ <Form {...form}> + field
┌───────────────▼─────────────────────────┐
│ shadcn Form (JSX + a11y wiring) │
│ id/htmlFor/aria-* tự nối · render lỗi │
└─────────────────────────────────────────┘
2. Cài đặt
npm install react-hook-form zod @hookform/resolvers
npx shadcn@latest add form input button
| Package | Vai trò |
|---|---|
react-hook-form | Động cơ quản state form (uncontrolled, ref-based) |
zod | Định nghĩa schema + validate + suy ra type |
@hookform/resolvers | Chất keo: gói zodResolver để RHF gọi zod khi validate |
shadcn form | Thêm Form, FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage vào components/ui/form.tsx (bạn sở hữu file này) |
Lệnh
shadcn add formkhông cài thư viện ngoài — nó chỉ copy code các component Form vào dự án bạn. Mởcomponents/ui/form.tsxra đọc được toàn bộ phần “nối dây a11y” ở mục 6.
3. Zod schema — bảng tra cứu đầy đủ
Zod là một DSL mô tả dữ liệu. Bạn ghép các “builder” lại bằng dấu chấm; mỗi builder thêm một luật. Đây là bộ bạn dùng 95% thời gian:
| Builder | Ý nghĩa | Ví dụ |
|---|---|---|
z.string() | Phải là chuỗi | z.string() |
.min(n, msg) | Độ dài ≥ n (chuỗi) hoặc giá trị ≥ n (số) | z.string().min(3, 'Tối thiểu 3 ký tự') |
.max(n, msg) | Độ dài / giá trị ≤ n | z.string().max(20) |
.length(n) | Độ dài đúng bằng n | z.string().length(6) |
.email(msg) | Chuỗi đúng dạng email | z.string().email('Email không hợp lệ') |
.url(msg) | Chuỗi đúng dạng URL | z.string().url() |
.uuid() | Chuỗi là UUID | z.string().uuid() |
.regex(re, msg) | Khớp biểu thức chính quy | z.string().regex(/^\w+$/, 'Chỉ chữ/số/_') |
.startsWith(s) / .endsWith(s) | Tiền/hậu tố | z.string().startsWith('https://') |
.trim() | Cắt khoảng trắng trước khi validate | z.string().trim().min(1) |
.toLowerCase() | Hạ chữ thường (transform) | z.string().toLowerCase() |
z.number() | Phải là số (kiểu number) | z.number() |
.int(msg) | Số nguyên | z.number().int() |
.positive() / .nonnegative() | > 0 / >= 0 | z.number().positive() |
.gt(n) / .gte(n) / .lt(n) / .lte(n) | So sánh | z.number().gte(13) |
z.coerce.number() | Ép input về số trước khi validate | z.coerce.number().int().min(13) |
z.boolean() | true/false (checkbox, switch) | z.boolean() |
z.enum([...]) | Một trong các literal | z.enum(['free', 'pro']) |
z.literal(v) | Đúng một giá trị | z.literal(true) (ô “đồng ý điều khoản”) |
z.date() | Đối tượng Date | z.coerce.date() |
z.array(t) | Mảng phần tử kiểu t | z.array(z.string()).min(1) |
z.object({...}) | Object có các field | z.object({ email: z.string() }) |
.optional() | Cho phép undefined | z.string().optional() |
.nullable() | Cho phép null | z.string().nullable() |
.default(v) | Giá trị mặc định khi thiếu | z.boolean().default(false) |
.refine(fn, msg) | Luật tùy biến (một field) | xem mục 9 |
.superRefine(fn) | Luật tùy biến + nhiều lỗi (cross-field) | xem mục 9 |
z.infer<typeof S> | Suy ra type từ schema | type V = z.infer<typeof S> |
Một schema thực tế ghép từ các builder trên:
import { z } from 'zod';
export const signupSchema = z.object({
username: z
.string()
.min(3, 'Tối thiểu 3 ký tự')
.max(20, 'Tối đa 20 ký tự')
.regex(/^\w+$/, 'Chỉ chữ, số và dấu _'),
email: z.string().email('Nhập email hợp lệ'),
age: z.coerce.number().int('Phải là số nguyên').min(13, 'Phải từ 13 tuổi'),
password: z.string().min(8, 'Tối thiểu 8 ký tự').regex(/\d/, 'Phải có ít nhất 1 chữ số'),
plan: z.enum(['free', 'pro'], { message: 'Chọn một gói' }),
acceptTerms: z.literal(true, { message: 'Bạn phải đồng ý điều khoản' }),
});
4. z.infer — một schema, vừa validate vừa làm type
Đây là ý tưởng cốt lõi khiến bộ ba này mạnh: schema sinh ra type, không phải ngược lại. Bạn không viết interface tay rồi viết validate tay (hai nguồn dễ lệch nhau). Bạn viết schema một lần, rồi rút type ra:
export type SignupValues = z.infer<typeof signupSchema>;
// TypeScript tự suy ra:
// {
// username: string;
// email: string;
// age: number; // ← number, không phải string, nhờ z.coerce.number()
// password: string;
// plan: 'free' | 'pro';
// acceptTerms: true;
// }
Cơ chế dưới mui: zod schema mang theo hai “kênh” — kênh runtime (hàm .parse() kiểm tra dữ liệu) và kênh type-level (một type phantom mà z.infer đọc ra). Khi bạn sửa min(3) thành min(5), type không đổi nhưng luật đổi; khi bạn thêm field phone, cả type lẫn luật đổi cùng lúc → TypeScript lập tức báo đỏ mọi nơi quên xử lý phone.
Vì sao quan trọng: với cách cũ (interface tách rời), bạn có thể thêm field vào interface mà quên thêm vào validate — compiler im lặng, bug lọt ra production. Với zod, không thể lệch: chỉ có một định nghĩa.
Lưu ý tinh tế về input vs output type. Khi dùng coerce, transform, hay default, type đầu vào (cái form nhận) khác type đầu ra (cái parse trả về). Trong 99% trường hợp với RHF bạn chỉ cần z.infer (= output). Nếu cần phân biệt: z.input<typeof S> và z.output<typeof S>.
5. useForm + zodResolver
useForm là hook trung tâm. Bạn truyền type qua generic và nối zod qua resolver:
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { signupSchema, type SignupValues } from './schema';
const form = useForm<SignupValues>({
resolver: zodResolver(signupSchema),
defaultValues: {
username: '',
email: '',
age: 13,
password: '',
plan: 'free',
acceptTerms: false,
},
mode: 'onBlur',
});
Các option của useForm đáng nhớ:
| Option | Tác dụng |
|---|---|
resolver | Hàm validate. zodResolver(schema) biến schema zod thành resolver của RHF |
defaultValues | Giá trị khởi tạo cho mọi field — bắt buộc khai báo đủ (xem gotcha controlled/uncontrolled) |
mode | Khi nào validate lần đầu: onSubmit (mặc định), onBlur, onChange, onTouched, all |
reValidateMode | Sau lần lỗi đầu, validate lại khi nào (mặc định onChange) |
values | Giá trị “điều khiển từ ngoài” — đổi prop này sẽ reset form (hữu ích khi load data async) |
shouldUnregister | Bỏ field khỏi state khi unmount (mặc định false) |
mode chọn sao? onBlur là điểm cân bằng tốt: không la mắng người dùng khi họ đang gõ (onChange quá hung hăng), nhưng cũng không bắt họ submit mới biết sai (onSubmit quá trễ).
form.formState — kho trạng thái bạn dùng để điều khiển UI:
| Thuộc tính | Ý nghĩa | Dùng để |
|---|---|---|
errors | Object lỗi theo field | Hiện thông báo (shadcn lo tự động) |
isValid | Toàn bộ form hợp lệ chưa | Bật/tắt nút submit |
isDirty | Người dùng đã sửa gì chưa | Cảnh báo “rời trang chưa lưu” |
dirtyFields | Những field cụ thể đã sửa | Chỉ gửi phần thay đổi |
touchedFields | Field nào đã được blur | Hiện lỗi sau khi rời field |
isSubmitting | Đang chạy onSubmit (async) | Disable nút, hiện spinner |
isSubmitSuccessful | Submit xong không lỗi | Hiện thông báo thành công |
submitCount | Số lần đã bấm submit | Đổi chiến lược sau N lần sai |
6. Giải phẫu shadcn Form — ai nối dây gì
Đây là phần “ma thuật” mà người mới hay dùng mà không hiểu. Bảy component, mỗi cái một nhiệm vụ:
| Component | Là gì | Làm gì |
|---|---|---|
Form | Provider mỏng (bọc FormProvider của RHF) | Đưa form xuống mọi con qua context |
FormField | Bọc Controller của RHF | Đăng ký một field; truyền field qua render-prop |
FormItem | div + sinh một id duy nhất | Gốc context cho cả nhóm; liên kết các phần qua id |
FormLabel | <Label> | htmlFor tự trỏ tới input; đỏ lên khi có lỗi |
FormControl | Slot bọc input | Tiêm id, aria-invalid, aria-describedby vào input |
FormDescription | Chữ phụ trợ | Có id; được input tham chiếu qua aria-describedby |
FormMessage | Chỗ hiện lỗi | Tự đọc lỗi zod của field; có id được aria-describedby trỏ tới |
Cơ chế nối dây. FormItem tạo một id gốc (ví dụ :r3:). Từ đó shadcn dẫn ra ba id: :r3:-form-item (input), :r3:-form-item-description (mô tả), :r3:-form-item-message (lỗi). Khi field không lỗi, input có aria-describedby="…-description". Khi có lỗi, input thành aria-invalid="true" và aria-describedby="…-description …-message" — screen reader đọc cả mô tả lẫn lỗi. Bạn không viết một dòng aria nào.
FormItem (sinh id gốc :r3:)
├─ FormLabel htmlFor=":r3:-form-item" ← click label → focus input
├─ FormControl ▶ Input
│ id=":r3:-form-item"
│ aria-invalid={hasError}
│ aria-describedby=
│ hasError ? ":r3:…-description :r3:…-message"
│ : ":r3:…-description"
├─ FormDescription id=":r3:-form-item-description"
└─ FormMessage id=":r3:-form-item-message" ← chứa text lỗi zod
So với HTML thuần — đây là toàn bộ thứ bạn không phải viết tay:
<!-- shadcn Form sinh ra tương đương thế này, tự động -->
<label for="email-x" class="…">Email</label>
<input id="email-x" aria-invalid="true"
aria-describedby="email-x-desc email-x-msg" />
<p id="email-x-desc">Chúng tôi không spam.</p>
<p id="email-x-msg" role="alert">Email không hợp lệ</p>
Ráp lại thành một field hoàn chỉnh:
import {
Form, FormField, FormItem, FormLabel,
FormControl, FormDescription, FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
export function SignupForm() {
const form = useForm<SignupValues>({
resolver: zodResolver(signupSchema),
defaultValues: { username: '', email: '', age: 13, password: '', plan: 'free', acceptTerms: false },
});
function onSubmit(values: SignupValues) {
// values đã được validate VÀ có type đầy đủ
console.log(values);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="ban@vidu.com" {...field} />
</FormControl>
<FormDescription>Chúng tôi không gửi spam.</FormDescription>
<FormMessage /> {/* tự render lỗi zod của field "email" */}
</FormItem>
)}
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
Tạo tài khoản
</Button>
</form>
</Form>
);
}
7. Object field — cái {...field} spread là gì
FormField dùng render-prop, đưa cho bạn một object field. Hiểu nó là hiểu vì sao {...field} đủ cho input chữ nhưng chưa đủ cho Select/Checkbox.
Khóa trong field | Là gì | Ai dùng |
|---|---|---|
value | Giá trị hiện tại của field | Input đọc để hiện |
onChange | Hàm cập nhật giá trị | Gọi khi giá trị đổi |
onBlur | Đánh dấu “đã chạm” + trigger validate (mode onBlur) | Gọi khi rời field |
name | Tên field (khớp key schema) | Để RHF biết field nào |
ref | Ref tới DOM input | RHF dùng để focus field lỗi, đọc giá trị |
disabled | Trạng thái disabled (nếu set) | Input |
Với <Input {...field} />, bạn spread cả 6 vào một <input> HTML — input hiểu hết value/onChange/onBlur/ref/name. Nhưng <Select> của Radix không nhận onChange (nó dùng onValueChange), cũng không nhận ref kiểu input. Nên với control phi-chữ bạn phải bind tay (mục 8).
8. Control phi-chữ — Select, Checkbox, Switch, RadioGroup, Combobox
Quy tắc chung: đọc giá trị từ field.value, ghi giá trị bằng field.onChange. FormControl luôn bọc đúng một phần tử “trigger” để nó tiêm id/aria vào.
Select — Radix dùng onValueChange và value:
<FormField control={form.control} name="plan" render={({ field }) => (
<FormItem>
<FormLabel>Gói</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger><SelectValue placeholder="Chọn gói" /></SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="free">Free</SelectItem>
<SelectItem value="pro">Pro</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)} />
Checkbox — boolean, dùng checked + onCheckedChange:
<FormField control={form.control} name="acceptTerms" render={({ field }) => (
<FormItem className="flex flex-row items-center gap-2 space-y-0">
<FormControl>
<Checkbox checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormLabel className="font-normal">Tôi đồng ý điều khoản</FormLabel>
<FormMessage />
</FormItem>
)} />
Switch — y hệt Checkbox về API (checked + onCheckedChange):
<FormField control={form.control} name="newsletter" render={({ field }) => (
<FormItem className="flex items-center justify-between">
<FormLabel>Nhận bản tin</FormLabel>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
</FormItem>
)} />
RadioGroup — value + onValueChange, các RadioGroupItem là lựa chọn:
<FormField control={form.control} name="plan" render={({ field }) => (
<FormItem className="space-y-2">
<FormLabel>Gói</FormLabel>
<FormControl>
<RadioGroup onValueChange={field.onChange} value={field.value} className="flex flex-col gap-2">
<FormItem className="flex items-center gap-2 space-y-0">
<FormControl><RadioGroupItem value="free" /></FormControl>
<FormLabel className="font-normal">Free</FormLabel>
</FormItem>
<FormItem className="flex items-center gap-2 space-y-0">
<FormControl><RadioGroupItem value="pro" /></FormControl>
<FormLabel className="font-normal">Pro</FormLabel>
</FormItem>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)} />
Combobox (Popover + Command) — không có onChange riêng; bạn gọi field.onChange(value) trong handler chọn item:
<FormField control={form.control} name="country" render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Quốc gia</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button variant="outline" role="combobox" className="justify-between">
{field.value
? countries.find((c) => c.value === field.value)?.label
: 'Chọn quốc gia'}
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0">
<Command>
<CommandInput placeholder="Tìm quốc gia…" />
<CommandList>
<CommandEmpty>Không thấy.</CommandEmpty>
<CommandGroup>
{countries.map((c) => (
<CommandItem key={c.value} value={c.value} onSelect={() => field.onChange(c.value)}>
{c.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)} />
Mẫu chung dễ nhớ: input chữ →
{...field}. Mọi thứ khác → đọcfield.value, ghifield.onChange(...), và đểFormControlbọc đúng một trigger để aria nối đúng phần tử focus được.
9. Validate liên-field — refine & superRefine
Builder thường (.min, .email…) chỉ kiểm một field. Khi luật cần so nhiều field với nhau (mật khẩu khớp xác nhận, ngày kết thúc sau ngày bắt đầu), bạn dùng .refine hoặc .superRefine ở cấp object.
.refine — một luật, một lỗi. Đặt path để lỗi gắn vào đúng field:
const passwordSchema = z
.object({
password: z.string().min(8, 'Tối thiểu 8 ký tự'),
confirm: z.string(),
})
.refine((data) => data.password === data.confirm, {
message: 'Mật khẩu không khớp',
path: ['confirm'], // ← lỗi hiện dưới field "confirm", không phải toàn form
});
.superRefine — nhiều luật / nhiều lỗi cùng lúc, kiểm soát từng lỗi qua ctx.addIssue:
const schema = z
.object({
password: z.string(),
confirm: z.string(),
startDate: z.coerce.date(),
endDate: z.coerce.date(),
})
.superRefine((data, ctx) => {
if (data.password !== data.confirm) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Mật khẩu không khớp', path: ['confirm'] });
}
if (data.endDate <= data.startDate) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Ngày kết thúc phải sau ngày bắt đầu', path: ['endDate'] });
}
});
.refine | .superRefine | |
|---|---|---|
| Số lỗi tạo được | Một (true/false) | Nhiều (ctx.addIssue bao lần) |
| Chỉ định field lỗi | path trong option | path trong từng issue |
| Khi nào dùng | Một luật cross-field đơn giản | Nhiều luật, hoặc lỗi có điều kiện |
Bẫy
path: quênpaththì lỗi gắn vào gốc form (""), nên<FormMessage />của field không hiện gì — người dùng tưởng form im lặng từ chối. Luôn trỏpathtới field bạn muốn báo đỏ.
10. Validate async / phía server + setError
Nhiều luật chỉ kiểm được ở server: “username đã tồn tại chưa”, “email đã đăng ký chưa”. Có hai tầng:
Tầng 1 — async refine (kiểm khi validate, ví dụ check trùng khi blur). refine nhận hàm async:
const schema = z.object({
username: z.string().min(3).refine(
async (name) => {
const res = await fetch(`/api/check-username?u=${encodeURIComponent(name)}`);
const { available } = await res.json();
return available;
},
{ message: 'Tên đăng nhập đã có người dùng' },
),
});
Tầng 2 — server trả lỗi lúc submit, rồi ánh xạ về form bằng setError. Đây là mẫu phổ biến nhất vì kiểm trùng lúc submit là chắc chắn nhất (tránh race condition):
async function onSubmit(values: SignupValues) {
const res = await fetch('/api/signup', { method: 'POST', body: JSON.stringify(values) });
if (!res.ok) {
const { fieldErrors } = await res.json();
// ánh xạ lỗi server → field tương ứng
for (const [field, message] of Object.entries(fieldErrors)) {
form.setError(field as keyof SignupValues, { message: message as string });
}
return;
}
// thành công…
}
Vì dùng lại đúng schema ở server, validate hai đầu không nhân đôi logic:
// server (Next.js route / API): cùng signupSchema
const parsed = signupSchema.safeParse(await req.json());
if (!parsed.success) {
// flatten().fieldErrors = { email: ['…'], username: ['…'] }
return Response.json({ fieldErrors: parsed.error.flatten().fieldErrors }, { status: 400 });
}
// parsed.data đã sạch & đúng type SignupValues
| Hàm zod | Khi lỗi | Khi ok | Dùng cho |
|---|---|---|---|
schema.parse(data) | Ném ZodError | Trả data | Khi muốn throw |
schema.safeParse(data) | { success: false, error } | { success: true, data } | Server (không muốn throw) |
error.flatten().fieldErrors | — | { field: string[] } | Map lỗi về từng field |
form.setError(name, { message }) | — | — | Đẩy lỗi server vào RHF |
Lưu ý: lỗi đặt bằng
setErrorsẽ bị xóa khi field đó được validate lại (người dùng sửa rồi blur). Đó là hành vi mong muốn — sửa xong thì lỗi “đã tồn tại” biến mất.
11. defaultValues, reset, và xử lý submit
defaultValues không chỉ là giá trị đầu — nó còn quyết định field là controlled hay uncontrolled (xem gotcha). Luôn khai báo đủ mọi field với giá trị đúng kiểu: chuỗi rỗng '', số 0, boolean false, mảng [].
Load data async (form sửa hồ sơ): đừng đặt vào defaultValues rỗng rồi quên — dùng prop values hoặc reset khi data về:
const form = useForm<ProfileValues>({ resolver: zodResolver(profileSchema), defaultValues: empty });
useEffect(() => {
if (data) form.reset(data); // điền form khi data tải xong
}, [data, form]);
reset sau khi submit thành công — về lại giá trị mặc định hoặc dữ liệu mới:
async function onSubmit(values: SignupValues) {
await api.signup(values);
form.reset(); // xóa form về defaultValues
// hoặc form.reset(values); // giữ giá trị vừa nhập làm "mặc định mới"
}
handleSubmit bọc hàm submit của bạn: nó chỉ gọi onSubmit khi validate pass, và tự đặt isSubmitting=true trong lúc hàm async chạy. Tham số thứ hai là onInvalid — chạy khi có lỗi:
<form onSubmit={form.handleSubmit(onSubmit, onInvalid)}>
function onInvalid(errors: FieldErrors<SignupValues>) {
// ví dụ: cuộn tới field lỗi đầu tiên, hoặc bắn toast
console.log('Form có lỗi:', errors);
}
12. Gotchas thường gặp
| Triệu chứng | Nguyên nhân | Cách xử lý |
|---|---|---|
| Cảnh báo “A component is changing an uncontrolled input to controlled” | defaultValues thiếu field đó (giá trị đầu là undefined) | Khai báo đủ mọi field trong defaultValues, dùng ''/0/false/[] |
| Input số luôn báo “Expected number, received string” | Input HTML trả chuỗi; schema dùng z.number() | Đổi sang z.coerce.number() để ép chuỗi → số trước khi validate |
| ”FormControl expects a single child” hoặc aria nối sai | FormControl bọc nhiều phần tử hoặc bọc sai trigger | Để FormControl bọc đúng một phần tử focus được (input / SelectTrigger / RadioGroupItem) |
<FormMessage /> không hiện lỗi cross-field | refine/superRefine thiếu path → lỗi gắn vào gốc form | Thêm path: ['fieldName'] |
| Select/Checkbox không cập nhật | Spread {...field} vào control phi-chữ | Bind tay: value+onValueChange (Select) hoặc checked+onCheckedChange (Checkbox/Switch) |
| Form không validate khi gõ | mode mặc định là onSubmit | Đặt mode: 'onBlur' hoặc 'onChange' |
| Nút submit không bao giờ disable lúc gửi | Đọc sai cờ | Dùng form.formState.isSubmitting, và để onSubmit là async |
| Form sửa hồ sơ không điền dữ liệu | Đặt data vào defaultValues sau khi đã mount | Dùng form.reset(data) trong useEffect, hoặc prop values |
| Lỗi server không hiện | Quên ánh xạ về field | form.setError('email', { message }) |
| Checkbox “đồng ý” cho qua dù chưa tick | Dùng z.boolean() (cho phép false) | Dùng z.literal(true, { message }) để bắt buộc tick |
Ba lỗi đầu bảng — uncontrolled→controlled, coerce cho số, FormControl một con — chiếm phần lớn câu hỏi của người mới. Thuộc ba cái này là tránh được 80% rắc rối.
13. Recipe — form đăng ký hoàn chỉnh
Ráp mọi thứ: schema (có cross-field), nhiều loại control, async submit + setError, reset, trạng thái nút.
// schema.ts
import { z } from 'zod';
export const signupSchema = z
.object({
username: z.string().min(3, 'Tối thiểu 3 ký tự').max(20).regex(/^\w+$/, 'Chỉ chữ, số, _'),
email: z.string().email('Email không hợp lệ'),
age: z.coerce.number().int().min(13, 'Phải từ 13 tuổi'),
password: z.string().min(8, 'Tối thiểu 8 ký tự').regex(/\d/, 'Phải có chữ số'),
confirm: z.string(),
plan: z.enum(['free', 'pro'], { message: 'Chọn một gói' }),
newsletter: z.boolean().default(false),
acceptTerms: z.literal(true, { message: 'Bạn phải đồng ý điều khoản' }),
})
.refine((d) => d.password === d.confirm, { message: 'Mật khẩu không khớp', path: ['confirm'] });
export type SignupValues = z.infer<typeof signupSchema>;
// SignupForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Form, FormField, FormItem, FormLabel,
FormControl, FormDescription, FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Switch } from '@/components/ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { signupSchema, type SignupValues } from './schema';
export function SignupForm() {
const form = useForm<SignupValues>({
resolver: zodResolver(signupSchema),
mode: 'onBlur',
defaultValues: {
username: '', email: '', age: 13, password: '', confirm: '',
plan: 'free', newsletter: false, acceptTerms: false,
},
});
async function onSubmit(values: SignupValues) {
const res = await fetch('/api/signup', { method: 'POST', body: JSON.stringify(values) });
if (!res.ok) {
const { fieldErrors } = await res.json();
for (const [field, message] of Object.entries(fieldErrors ?? {})) {
form.setError(field as keyof SignupValues, { message: message as string });
}
return;
}
form.reset(); // thành công → xóa form
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField control={form.control} name="username" render={({ field }) => (
<FormItem>
<FormLabel>Tên đăng nhập</FormLabel>
<FormControl><Input placeholder="janedoe" {...field} /></FormControl>
<FormDescription>3–20 ký tự, chữ/số/dấu _.</FormDescription>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="email" render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl><Input type="email" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="age" render={({ field }) => (
<FormItem>
<FormLabel>Tuổi</FormLabel>
{/* type="number" nhưng giá trị vẫn là chuỗi → cần z.coerce.number() ở schema */}
<FormControl><Input type="number" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="password" render={({ field }) => (
<FormItem>
<FormLabel>Mật khẩu</FormLabel>
<FormControl><Input type="password" {...field} /></FormControl>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="confirm" render={({ field }) => (
<FormItem>
<FormLabel>Xác nhận mật khẩu</FormLabel>
<FormControl><Input type="password" {...field} /></FormControl>
<FormMessage /> {/* lỗi "không khớp" gắn vào đây nhờ path: ['confirm'] */}
</FormItem>
)} />
<FormField control={form.control} name="plan" render={({ field }) => (
<FormItem>
<FormLabel>Gói</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger><SelectValue placeholder="Chọn gói" /></SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="free">Free</SelectItem>
<SelectItem value="pro">Pro</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)} />
<FormField control={form.control} name="newsletter" render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-3">
<FormLabel className="font-normal">Nhận bản tin</FormLabel>
<FormControl><Switch checked={field.value} onCheckedChange={field.onChange} /></FormControl>
</FormItem>
)} />
<FormField control={form.control} name="acceptTerms" render={({ field }) => (
<FormItem className="flex flex-row items-start gap-2 space-y-0">
<FormControl><Checkbox checked={field.value} onCheckedChange={field.onChange} /></FormControl>
<div className="space-y-1 leading-none">
<FormLabel className="font-normal">Tôi đồng ý điều khoản dịch vụ</FormLabel>
<FormMessage />
</div>
</FormItem>
)} />
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Đang tạo…' : 'Tạo tài khoản'}
</Button>
</form>
</Form>
);
}
Mọi mảnh đều thấy ở đây: schema cross-field (refine + path), z.coerce.number() cho tuổi, z.literal(true) cho điều khoản, Select/Switch/Checkbox bind tay, async onSubmit + setError, reset khi xong, và nút theo isSubmitting.
14. Bài tập
1. Vì sao một schema zod tốt hơn interface TS riêng + validate thủ công?
Lời giải
Schema là nguồn sự thật duy nhất: z.infer suy ra type nên không thể lệch khỏi luật validate, và cùng schema chạy được ở client lẫn server. Với interface tách rời, bạn có thể thêm field vào type mà quên thêm vào validate (hoặc ngược lại) — compiler im lặng, bug lọt ra production.
2. Vì sao dùng z.coerce.number() cho input tuổi thay vì z.number()?
Lời giải
Input HTML (kể cả type="number") cho ra chuỗi; z.number() sẽ báo “Expected number, received string”. coerce chuyển chuỗi thành số trước khi validate, nên "18" thành 18.
3. <FormMessage /> giúp bạn khỏi viết gì?
Lời giải
Khỏi viết {errors.email && <p role="alert">{errors.email.message}</p>} thủ công, và khỏi tự nối aria-describedby/aria-invalid. Nó tự đọc lỗi zod của field và nối ARIA tới input.
4. Vì sao gõ một field trong RHF không khiến cả form re-render, còn useState mỗi field thì có?
Lời giải
RHF dùng uncontrolled — input giữ giá trị trong DOM, RHF theo dõi qua ref ngoài vòng render của React; gõ phím không gọi setState nên không re-render. useState thì mỗi keystroke gọi setState → React render lại component (và mọi field con).
5. Bạn cần “mật khẩu” và “xác nhận mật khẩu” phải khớp, và muốn lỗi hiện dưới ô xác nhận. Viết phần schema đó.
Lời giải
z.object({
password: z.string().min(8),
confirm: z.string(),
}).refine((d) => d.password === d.confirm, {
message: 'Mật khẩu không khớp',
path: ['confirm'], // ← gắn lỗi vào field "confirm"
});Thiếu path thì lỗi gắn vào gốc form và <FormMessage /> của ô confirm sẽ không hiện gì.
6. Server trả { fieldErrors: { email: ['Email đã tồn tại'] } } khi submit. Làm sao hiện lỗi này đúng dưới ô email?
Lời giải
Trong onSubmit, sau khi nhận response lỗi, gọi form.setError:
form.setError('email', { message: 'Email đã tồn tại' });Lỗi này tự biến mất khi người dùng sửa ô email rồi blur (RHF validate lại field).
Nâng cao: trong lab, sửa mọi field thành hợp lệ và xem isValid thành true cùng object errors rỗng đi; rồi cố tick “đồng ý” thành bỏ tick và quan sát z.literal(true) chặn submit.
Điểm chính
- Form khó vì gộp state + validate + a11y + hiệu năng + type vào một chỗ; bộ ba RHF + zod + shadcn Form chia mỗi việc cho đúng công cụ.
- RHF nhanh nhờ uncontrolled: input giữ giá trị trong DOM, theo dõi qua
ref, nên gõ phím không re-render cả form — khác hẳnuseStatemỗi field. - Một schema zod vừa validate (runtime) vừa sinh type (
z.infer), nên type và luật không bao giờ lệch. Dùngz.coerce.number()cho input số,z.literal(true)cho ô bắt buộc tick. refine/superRefinelo validate liên-field; nhớpath: ['field']để lỗi gắn đúng chỗ.- shadcn Form nối a11y tự động:
FormItemsinh id,FormControltiêmid/aria-invalid/aria-describedby,FormMessagerender lỗi zod — bạn không viết một dòngarianào. - Input chữ →
{...field}; control phi-chữ (Select/Checkbox/Switch/RadioGroup/Combobox) → đọcfield.value, ghifield.onChange, đểFormControlbọc đúng một trigger. - Dùng lại schema ở server (
safeParse→flatten().fieldErrors) và ánh xạ lỗi về form bằngform.setError— validate đầu-cuối không nhân đôi logic.
Tiếp theo
Phần 12 — Mẫu pro & capstone: hệ variant dựa cva, data table sắp xếp được (TanStack Table), command palette (cmdk), toast (sonner), mẫu kết hợp, và một dashboard ráp cả series lại.