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.
| Library | Bundle | Re-render mỗi key | TS DX |
|---|---|---|---|
| react-hook-form | ~10KB | 1 (chỉ field) | Excellent |
| Formik | ~13KB | Toàn form | OK |
| Final Form | ~7KB | Toàn form | OK |
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.