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.
Forms are where UI gets genuinely hard: validation, error messages, accessibility, performance, and type safety all at once {Form là nơi UI thật sự khó: validate, thông báo lỗi, khả năng tiếp cận, hiệu năng, và type safety cùng lúc}. The modern stack solves each cleanly — zod (schema + validation), react-hook-form (state with minimal re-renders), and shadcn’s Form components (accessibility wiring) {Stack hiện đại giải gọn từng phần — zod (schema + validate), react-hook-form (state với re-render tối thiểu), và các component Form của shadcn (nối dây a11y)}. Together they’re a joy {Ghép lại thì rất sướng}.
Try it first — type invalid values and watch field errors and form state react {Thử trước — gõ giá trị sai và xem lỗi field cùng form state phản ứng}:
1. Install {Cài đặt}
npm install react-hook-form zod @hookform/resolvers
npx shadcn@latest add form input button
@hookform/resolvers is the glue between zod and react-hook-form {@hookform/resolvers là chất keo giữa zod và react-hook-form}. The shadcn form adds Form, FormField, FormItem, FormLabel, FormControl, FormMessage {shadcn form thêm Form, FormField, FormItem, FormLabel, FormControl, FormMessage}.
2. The schema is the single source of truth {Schema là nguồn sự thật duy nhất}
Define the shape once with zod {Định nghĩa hình dạng một lần bằng zod}. It does double duty: it validates at runtime and types your form via inference {Nó làm hai việc: validate lúc runtime và type form qua suy luận}:
import { z } from 'zod';
export const signupSchema = z.object({
username: z.string().min(3, 'At least 3 characters').max(20).regex(/^\w+$/, 'Letters, numbers, _ only'),
email: z.string().email('Enter a valid email'),
age: z.coerce.number().int().min(13, 'Must be at least 13'),
password: z.string().min(8, 'At least 8 characters').regex(/\d/, 'Include a number'),
});
// the form's TS type — derived, never hand-written
export type SignupValues = z.infer<typeof signupSchema>;
No duplicate interface, no drift between validation and types {Không interface trùng, không lệch giữa validate và type}. Change the schema and TypeScript instantly flags every affected usage {Đổi schema và TypeScript lập tức báo mọi nơi bị ảnh hưởng}. Note z.coerce.number() — form inputs are strings, and coerce converts before validating {Để ý z.coerce.number() — input form là chuỗi, coerce chuyển đổi trước khi validate}.
3. Wire it with react-hook-form {Nối với react-hook-form}
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: '' },
mode: 'onBlur', // validate when a field loses focus
});
Why react-hook-form over useState per field? Performance and ergonomics {Sao dùng react-hook-form thay vì useState mỗi field? Hiệu năng và trải nghiệm}. It tracks inputs via refs (uncontrolled), so typing in one field doesn’t re-render the whole form {Nó theo dõi input qua ref (uncontrolled), nên gõ một field không re-render cả form}. You get formState.errors, isValid, isDirty, isSubmitting for free {Bạn có formState.errors, isValid, isDirty, isSubmitting sẵn}.
4. Render with shadcn Form components {Render với component Form của shadcn}
The shadcn Form parts wire up accessibility automatically — label↔input association, aria-invalid, aria-describedby to the error, focus management {Các phần Form của shadcn nối a11y tự động — gắn label↔input, aria-invalid, aria-describedby tới lỗi, quản lý focus}:
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage, FormDescription } 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: { /* … */ } });
function onSubmit(values: SignupValues) {
// values is fully typed AND already validated
console.log(values);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="janedoe" {...field} />
</FormControl>
<FormDescription>3–20 chars, letters/numbers/underscore.</FormDescription>
<FormMessage /> {/* renders the zod error for this field */}
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl><Input type="email" {...field} /></FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
Create account
</Button>
</form>
</Form>
);
}
FormMessage shows the zod error automatically — you never write {errors.email && <p>…</p>} {FormMessage hiện lỗi zod tự động — bạn không bao giờ viết {errors.email && <p>…</p>}}. {...field} spreads value, onChange, onBlur, ref from react-hook-form onto the input {{...field} spread value, onChange, onBlur, ref từ react-hook-form vào input}.
5. Beyond text inputs {Ngoài input chữ}
The same FormField pattern wraps any control — a shadcn Select, Checkbox, Switch, RadioGroup {Cùng mẫu FormField bọc mọi control — Select, Checkbox, Switch, RadioGroup của shadcn}. For non-text controls you bind explicitly {Với control không phải chữ, bạn bind tường minh}:
<FormField control={form.control} name="plan" render={({ field }) => (
<FormItem>
<FormLabel>Plan</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl><SelectTrigger><SelectValue /></SelectTrigger></FormControl>
<SelectContent>
<SelectItem value="free">Free</SelectItem>
<SelectItem value="pro">Pro</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)} />
6. Server-side validation & errors {Validate phía server & lỗi}
Reuse the same schema on the server (it’s just zod) and map server errors back into the form {Tái dùng cùng schema trên server (chỉ là zod) và ánh xạ lỗi server về form}:
// server: parse the same schema
const parsed = signupSchema.safeParse(body);
if (!parsed.success) return { errors: parsed.error.flatten().fieldErrors };
// client: surface a field error returned by the server
form.setError('email', { message: 'This email is already taken' });
One schema, validated on both ends — no logic duplication {Một schema, validate cả hai đầu — không nhân đôi logic}.
7. Exercises {Bài tập}
1. Why is a single zod schema better than a separate TS interface + manual validation? {Vì sao một schema zod tốt hơn interface TS riêng + validate thủ công?}
Solution {Lời giải}
The schema is the single source of truth: z.infer derives the type so it can’t drift from the validation rules, and the same schema runs on client and server {Schema là nguồn sự thật duy nhất: z.infer suy ra type nên không lệch khỏi luật validate, và cùng schema chạy ở client lẫn server}.
2. Why z.coerce.number() for an age input instead of z.number()? {Vì sao dùng z.coerce.number() cho input tuổi thay vì z.number()?}
Solution {Lời giải}
HTML inputs produce strings; coerce converts the string to a number before validating, so "18" becomes 18 {Input HTML cho ra chuỗi; coerce chuyển chuỗi thành số trước khi validate, nên "18" thành 18}.
3. What does <FormMessage /> save you from writing? {<FormMessage /> giúp bạn khỏi viết gì?}
Solution {Lời giải}
Manual error rendering and wiring ({errors.field && <p id=… >…</p>} plus aria-describedby) — it shows the field’s error and connects ARIA automatically {Render lỗi và nối dây thủ công — nó hiện lỗi của field và nối ARIA tự động}.
Stretch {Nâng cao}: in the lab, fix all fields to valid values and watch isValid flip to true and the errors object empty out {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}.
Key takeaways {Điểm chính}
- One zod schema types and validates the form —
z.inferremoves type drift {Một schema zod type và validate form —z.inferxóa lệch type}. react-hook-formgives performant (uncontrolled) state and richformState;zodResolverconnects them {react-hook-formcho state hiệu năng cao (uncontrolled) vàformStatephong phú;zodResolvernối chúng}.- shadcn
Formparts wire accessibility and error display for free (FormMessage,aria-*) {Các phầnFormcủa shadcn nối a11y và hiển thị lỗi miễn phí}. - Reuse the schema on the server for end-to-end validation with no duplication {Tái dùng schema trên server để validate đầu-cuối không nhân đôi}.
Next up {Tiếp theo}
Part 12 — Pro patterns & capstone: a cva-based variant system, a sortable data table (TanStack Table), a command palette (cmdk), toasts (sonner), composition patterns, and a dashboard that ties the whole series together {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}.