jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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, đả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ắn id/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ạnCông cụ chịu trách nhiệmCâu hỏi cần đúng
Khởi tạodefaultValues + schemaGiá trị rỗng có hợp lệ với type không?
Nhập liệuRHF register/controllerGõ có re-render quá nhiều không?
Validate clientzod + zodResolverLỗi có đúng field và đúng thời điểm không?
Validate serveraction/API + setErrorEmail trùng, permission, rule backend hiển thị ra sao?
Submit pendingformState.isSubmittingCó khóa nút, chống double submit không?
Thành côngreset, toast, redirectForm trở về trạng thái nào?
Khả năng tiếp cậnshadcn FormField/FormMessagelabel, 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
PackageVai trò
react-hook-formĐộng cơ quản state form (uncontrolled, ref-based)
zodĐịnh nghĩa schema + validate + suy ra type
@hookform/resolversChất keo: gói zodResolver để RHF gọi zod khi validate
shadcn formThê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 form khô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.tsx ra đọ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ĩaVí dụ
z.string()Phải là chuỗiz.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ị ≤ nz.string().max(20)
.length(n)Độ dài đúng bằng nz.string().length(6)
.email(msg)Chuỗi đúng dạng emailz.string().email('Email không hợp lệ')
.url(msg)Chuỗi đúng dạng URLz.string().url()
.uuid()Chuỗi là UUIDz.string().uuid()
.regex(re, msg)Khớp biểu thức chính quyz.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 validatez.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ênz.number().int()
.positive() / .nonnegative()> 0 / >= 0z.number().positive()
.gt(n) / .gte(n) / .lt(n) / .lte(n)So sánhz.number().gte(13)
z.coerce.number()Ép input về số trước khi validatez.coerce.number().int().min(13)
z.boolean()true/false (checkbox, switch)z.boolean()
z.enum([...])Một trong các literalz.enum(['free', 'pro'])
z.literal(v)Đúng một giá trịz.literal(true) (ô “đồng ý điều khoản”)
z.date()Đối tượng Datez.coerce.date()
z.array(t)Mảng phần tử kiểu tz.array(z.string()).min(1)
z.object({...})Object có các fieldz.object({ email: z.string() })
.optional()Cho phép undefinedz.string().optional()
.nullable()Cho phép nullz.string().nullable()
.default(v)Giá trị mặc định khi thiếuz.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ừ schematype 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>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ớ:

OptionTác dụng
resolverHàm validate. zodResolver(schema) biến schema zod thành resolver của RHF
defaultValuesGiá trị khởi tạo cho mọi field — bắt buộc khai báo đủ (xem gotcha controlled/uncontrolled)
modeKhi nào validate lần đầu: onSubmit (mặc định), onBlur, onChange, onTouched, all
reValidateModeSau lần lỗi đầu, validate lại khi nào (mặc định onChange)
valuesGiá trị “điều khiển từ ngoài” — đổi prop này sẽ reset form (hữu ích khi load data async)
shouldUnregisterBỏ 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ĩaDùng để
errorsObject lỗi theo fieldHiện thông báo (shadcn lo tự động)
isValidToàn bộ form hợp lệ chưaBật/tắt nút submit
isDirtyNgười dùng đã sửa gì chưaCảnh báo “rời trang chưa lưu”
dirtyFieldsNhững field cụ thể đã sửaChỉ gửi phần thay đổi
touchedFieldsField nào đã được blurHiện lỗi sau khi rời field
isSubmittingĐang chạy onSubmit (async)Disable nút, hiện spinner
isSubmitSuccessfulSubmit xong không lỗiHiện thông báo thành công
submitCountSố 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ụ:

ComponentLà gìLàm gì
FormProvider mỏng (bọc FormProvider của RHF)Đưa form xuống mọi con qua context
FormFieldBọc Controller của RHFĐăng ký một field; truyền field qua render-prop
FormItemdiv + sinh một id duy nhấtGố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
FormControlSlot bọc inputTiêm id, aria-invalid, aria-describedby vào input
FormDescriptionChữ phụ trợid; được input tham chiếu qua aria-describedby
FormMessageChỗ hiện lỗiTự đọ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"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 fieldLà gìAi dùng
valueGiá trị hiện tại của fieldInput đọc để hiện
onChangeHà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
nameTên field (khớp key schema)Để RHF biết field nào
refRef tới DOM inputRHF dùng để focus field lỗi, đọc giá trị
disabledTrạ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 onValueChangevalue:

<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>
)} />

RadioGroupvalue + 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 → đọc field.value, ghi field.onChange(...), và để FormControl bọ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 đượcMột (true/false)Nhiều (ctx.addIssue bao lần)
Chỉ định field lỗipath trong optionpath trong từng issue
Khi nào dùngMột luật cross-field đơn giảnNhiều luật, hoặc lỗi có điều kiện

Bẫy path: quên path thì 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ỏ path tớ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…
}

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 zodKhi lỗiKhi okDùng cho
schema.parse(data)Ném ZodErrorTrả dataKhi 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 setError sẽ 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ứngNguyên nhânCá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 saiFormControl 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-fieldrefine/superRefine thiếu path → lỗi gắn vào gốc formThêm path: ['fieldName']
Select/Checkbox không cập nhậtSpread {...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à để onSubmitasync
Form sửa hồ sơ không điền dữ liệuĐặt data vào defaultValues sau khi đã mountDùng form.reset(data) trong useEffect, hoặc prop values
Lỗi server không hiệnQuên ánh xạ về fieldform.setError('email', { message })
Checkbox “đồng ý” cho qua dù chưa tickDù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ẳn useState mỗ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ùng z.coerce.number() cho input số, z.literal(true) cho ô bắt buộc tick.
  • refine/superRefine lo validate liên-field; nhớ path: ['field'] để lỗi gắn đúng chỗ.
  • shadcn Form nối a11y tự động: FormItem sinh id, FormControl tiêm id/aria-invalid/aria-describedby, FormMessage render lỗi zod — bạn không viết một dòng aria nào.
  • Input chữ → {...field}; control phi-chữ (Select/Checkbox/Switch/RadioGroup/Combobox) → đọc field.value, ghi field.onChange, để FormControl bọc đúng một trigger.
  • Dùng lại schema ở server (safeParseflatten().fieldErrors) và ánh xạ lỗi về form bằng form.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.