Bỏ qua đến nội dung chính
react-hook-formZodformvalidationfrontend

Form Handling React: react-hook-form + Zod Validation

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.

Xuất bản 8 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.

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

npm install react-hook-form zod @hookform/resolvers
'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

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

const 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

function 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

const 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.

Zalo