Bỏ qua menu, vào nội dung chính

Xử lý form trong React với react-hook-form và Zod

Form handling React với react-hook-form và Zod: validation type-safe, error UX, async submit, file upload. Code production-ready, ít re-render.

3 phút đọc

Form handling React 2026 = react-hook-form + Zod. Bài này code đầy đủ form đăng ký với validation type-safe, error UX, async submit, server action. Production-ready.

Trang Get started của React Hook Form
Trang Get started của React Hook Form (react-hook-form.com), ảnh chụp ngày 27/09/2026.

Vì sao react-hook-form?

Form 20 field với Formik: mỗi keystroke re-render toàn form -> input giật. RHF dùng ref + uncontrolled - chỉ component subscribe field đó re-render.

LibraryBundleRe-render mỗi keyTS DX
react-hook-form~10KB1 (chỉ field)Excellent
Formik~13KBToàn formOK
Final Form~7KBToàn formOK

Setup + form đầu tiên

bashnpm install react-hook-form zod @hookform/resolvers
tsx'use client'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'

const registerSchema = z.object({
  email: z.string().email('Email không hợp lệ'),
  password: z.string()
    .min(8, 'Tối thiểu 8 ký tự')
    .regex(/[A-Z]/, 'Phải có chữ hoa')
    .regex(/[0-9]/, 'Phải có số'),
  age: z.coerce.number().int().min(13, 'Phải đủ 13 tuổi'),
  acceptTerms: z.literal(true, { errorMap: () => ({ message: 'Phải đồng ý điều khoản' }) }),
})

type RegisterInput = z.infer<typeof registerSchema>

export default function RegisterForm() {
  const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<RegisterInput>({
    resolver: zodResolver(registerSchema),
    mode: 'onBlur',  // validate khi blur, không khi đang gõ
  })

  async function onSubmit(data: RegisterInput) {
    const res = await fetch('/api/register', {
      method: 'POST',
      body: JSON.stringify(data),
    })
    if (!res.ok) { /* show toast */ return }
    // success
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <div>
        <label>Email</label>
        <input {...register('email')} type="email" />
        {errors.email && <p className="text-red-500">{errors.email.message}</p>}
      </div>

      <div>
        <label>Mật khẩu</label>
        <input {...register('password')} type="password" />
        {errors.password && <p className="text-red-500">{errors.password.message}</p>}
      </div>

      <div>
        <label>Tuổi</label>
        <input {...register('age')} type="number" />
        {errors.age && <p className="text-red-500">{errors.age.message}</p>}
      </div>

      <label>
        <input {...register('acceptTerms')} type="checkbox" /> Đồng ý điều khoản
      </label>
      {errors.acceptTerms && <p className="text-red-500">{errors.acceptTerms.message}</p>}

      <button disabled={isSubmitting}>
        {isSubmitting ? 'Đang gửi...' : 'Đăng ký'}
      </button>
    </form>
  )
}

Server validation cùng schema

typescript// app/api/register/route.ts
import { registerSchema } from '@/schemas/register'  // import cùng schema

export async function POST(req: Request) {
  const body = await req.json()
  const result = registerSchema.safeParse(body)
  if (!result.success) {
    return Response.json({ errors: result.error.flatten() }, { status: 400 })
  }
  // result.data type-safe
  await db.users.create(result.data)
  return Response.json({ ok: true })
}

Schema chia sẻ -> client validate UX, server validate security. Một source of truth.

Dynamic field với useFieldArray

tsxconst invoiceSchema = z.object({
  customer: z.string(),
  items: z.array(z.object({
    name: z.string().min(1),
    price: z.coerce.number().positive(),
    qty: z.coerce.number().int().positive(),
  })).min(1, 'Phải có ít nhất 1 item'),
})

function InvoiceForm() {
  const { register, control, handleSubmit } = useForm({ resolver: zodResolver(invoiceSchema) })
  const { fields, append, remove } = useFieldArray({ control, name: 'items' })

  return (
    <form onSubmit={handleSubmit(submit)}>
      <input {...register('customer')} placeholder="Khách hàng" />

      {fields.map((field, idx) => (
        <div key={field.id} className="flex gap-2">
          <input {...register(`items.${idx}.name`)} placeholder="Sản phẩm" />
          <input {...register(`items.${idx}.price`)} type="number" />
          <input {...register(`items.${idx}.qty`)} type="number" />
          <button type="button" onClick={() => remove(idx)}>Xoá</button>
        </div>
      ))}

      <button type="button" onClick={() => append({ name: '', price: 0, qty: 1 })}>
        Thêm item
      </button>
    </form>
  )
}

watch + dependent field

tsxfunction ShippingForm() {
  const { register, watch } = useForm()
  const country = watch('country')

  return (
    <>
      <select {...register('country')}>
        <option value="VN">Việt Nam</option>
        <option value="US">USA</option>
      </select>

      {country === 'VN' && (
        <select {...register('province')}>
          <option>Hà Nội</option>
          <option>TP.HCM</option>
        </select>
      )}

      {country === 'US' && (
        <input {...register('zip')} placeholder="ZIP" />
      )}
    </>
  )
}

Async validation

tsxconst schema = z.object({
  username: z.string().min(3).refine(
    async (val) => {
      const res = await fetch(`/api/check-username?u=${val}`)
      return res.ok
    },
    { message: 'Username đã tồn tại' }
  ),
})

Hoặc dùng setError manual sau submit nếu server reject.

Kết luận

react-hook-form + Zod là combo chuẩn 2026 cho mọi form React. Re-render tối thiểu, validation type-safe, schema share client + server. Setup ban đầu 30 phút, rồi mọi form sau làm trong 5 phút. Đọc thêm State management nếu form state cần share giữa nhiều page.

Nhận bài mới

Để lại email để nhận bài mới của series Kiến thức lập trình, hoặc theo dõi bằng RSS.

RSS Kiến thức lập trình

Email chỉ dùng để gửi bài mới, xem Chính sách bảo mật.

Trao đổi về dự án

Cần tư vấn?