2 giờ sáng, điện thoại reng. Product manager nhắn tin báo form đăng ký trên production đang nhận email sai định dạng — user nhập abc@ cũng pass qua, server crash. Mở code ra xem thì thấy một đống if/else validation viết tay từ 6 tháng trước, logic rối như mớ bòng bong mà không ai dám đụng vào.
Sau đêm đó, mình quyết định refactor toàn bộ form validation trong dự án. Đó cũng là lần đầu mình dùng bộ đôi React Hook Form + Zod một cách nghiêm túc. Từ đó đến giờ không bao giờ quay lại cách cũ nữa.
Vấn đề với form validation kiểu cũ
Cách phổ biến nhất mà hầu hết mọi người mới học React đều dùng:
const [email, setEmail] = useState('');
const [error, setError] = useState('');
const handleSubmit = () => {
if (!email) {
setError('Email không được để trống');
return;
}
if (!email.includes('@')) {
setError('Email không hợp lệ');
return;
}
// ... submit
};
Form 2-3 field thì ổn. Nhưng khi có 10+ field với 3-4 rule mỗi field, code nhanh chóng thành cơn ác mộng bảo trì. TypeScript không bắt được lỗi kiểu dữ liệu qua đống if/else này — bug validation chỉ lộ ra lúc runtime, thường là lúc 2 giờ sáng trên production.
Mình từng refactor một codebase 50K lines và bài học lớn nhất là phải có test coverage tốt trước khi bắt đầu. Nhưng với validation logic nằm rải rác khắp nơi kiểu này, viết test cũng cực kỳ khó — mỗi component tự xử lý theo cách riêng, không có gì nhất quán.
Zod và React Hook Form — hai mảnh ghép hoàn hảo
Zod: Schema là single source of truth
Zod không chỉ validate dữ liệu — nó generate TypeScript types trực tiếp từ schema. Khai báo một lần, Zod lo phần còn lại:
import { z } from 'zod';
const registerSchema = z.object({
email: z.string().email('Email không hợp lệ'),
password: z.string().min(8, 'Mật khẩu tối thiểu 8 ký tự'),
age: z.number().min(18, 'Phải đủ 18 tuổi'),
});
// TypeScript tự infer type từ schema — không cần khai báo thủ công
type RegisterForm = z.infer<typeof registerSchema>;
// { email: string; password: string; age: number }
Mình thích nhất chỗ này: cùng một schema, dùng được ở cả client lẫn server. Muốn đổi rule validate email? Sửa một chỗ là xong — frontend và API tự đồng bộ, không cần nhớ phải cập nhật hai nơi.
React Hook Form: Performance không đánh đổi DX
React Hook Form quản lý state qua uncontrolled inputs với ref. User gõ từng ký tự? Không có re-render nào cả. Bundle size chỉ ~9KB gzip — nhẹ hơn Formik (~15KB) và nhanh hơn rõ rệt trên form nhiều field. Kết hợp với Zod qua package @hookform/resolvers:
npm install react-hook-form zod @hookform/resolvers
Thực hành: Xây dựng form đăng ký type-safe từ đầu đến cuối
Bước 1: Định nghĩa Zod schema
// schemas/register.ts
import { z } from 'zod';
export const registerSchema = z.object({
name: z.string().min(2, 'Tên phải có ít nhất 2 ký tự').max(50),
email: z.string().email('Địa chỉ email không hợp lệ'),
password: z
.string()
.min(8, 'Mật khẩu tối thiểu 8 ký tự')
.regex(/[A-Z]/, 'Phải có ít nhất 1 chữ hoa')
.regex(/[0-9]/, 'Phải có ít nhất 1 chữ số'),
confirmPassword: z.string(),
}).refine(
(data) => data.password === data.confirmPassword,
{
message: 'Mật khẩu xác nhận không khớp',
path: ['confirmPassword'],
}
);
export type RegisterFormData = z.infer<typeof registerSchema>;
Chú ý .refine() — dùng để validate cross-field (so sánh hai field với nhau). Đây là thứ mà validation kiểu cũ rất khó làm sạch, thường dẫn đến bug lúc có edge case.
Bước 2: Kết hợp với React Hook Form
// components/RegisterForm.tsx
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { registerSchema, RegisterFormData } from '@/schemas/register';
export function RegisterForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
setError,
} = useForm<RegisterFormData>({
resolver: zodResolver(registerSchema),
});
const onSubmit = async (data: RegisterFormData) => {
try {
const response = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
const errorData = await response.json();
// Gán lỗi server-side vào đúng field
errorData.errors?.forEach((err: { field: string; message: string }) => {
setError(err.field as keyof RegisterFormData, {
message: err.message,
});
});
}
} catch {
setError('root', { message: 'Lỗi kết nối. Thử lại sau.' });
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label>Email</label>
<input {...register('email')} type="email" placeholder="Email" />
{errors.email && <p className="error">{errors.email.message}</p>}
</div>
<div>
<label>Mật khẩu</label>
<input {...register('password')} type="password" />
{errors.password && <p className="error">{errors.password.message}</p>}
</div>
<div>
<label>Xác nhận mật khẩu</label>
<input {...register('confirmPassword')} type="password" />
{errors.confirmPassword && (
<p className="error">{errors.confirmPassword.message}</p>
)}
</div>
{errors.root && <p className="error-global">{errors.root.message}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Đang xử lý...' : 'Đăng ký'}
</button>
</form>
);
}
handleSubmit của RHF chỉ gọi onSubmit khi Zod validate thành công — nếu schema fail, các error tự động xuất hiện đúng field, không cần xử lý thủ công.
Bước 3: Validate lại ở server với cùng schema
Client-side validation là UX, không phải security. Server luôn phải validate lại — và đây là lúc dùng chung schema với client tỏa sáng:
// app/api/register/route.ts
import { NextResponse } from 'next/server';
import { registerSchema } from '@/schemas/register';
import { ZodError } from 'zod';
export async function POST(request: Request) {
try {
const body = await request.json();
// Parse + validate cùng schema với client
const validatedData = registerSchema.parse(body);
// validatedData đã được TypeScript type-check đầy đủ
await createUser(validatedData);
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof ZodError) {
return NextResponse.json(
{
errors: error.errors.map(e => ({
field: e.path.join('.'),
message: e.message,
}))
},
{ status: 400 }
);
}
return NextResponse.json(
{ message: 'Internal server error' },
{ status: 500 }
);
}
}
Bước 4: Dùng với Next.js Server Actions
Đang dùng Next.js App Router? Flow còn gọn hơn nhiều với Server Actions:
// app/register/actions.ts
'use server';
import { registerSchema } from '@/schemas/register';
export async function registerAction(formData: FormData) {
const rawData = Object.fromEntries(formData);
// safeParse không throw — trả về { success, data } hoặc { success, error }
const result = registerSchema.safeParse(rawData);
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors,
};
}
// result.data hoàn toàn type-safe
await createUser(result.data);
return { success: true };
}
Dùng safeParse() khi không muốn throw exception, parse() khi muốn để error bubble up qua try/catch.
Một số pattern hay gặp
- Optional field với default:
z.string().optional().default('') - Enum:
z.enum(['admin', 'user', 'guest'])— TypeScript tự infer union type - Transform:
z.string().transform(val => val.trim().toLowerCase())— làm sạch dữ liệu ngay trong schema, không cần xử lý thủ công - Số từ string (hay gặp với FormData):
z.coerce.number().min(0) - Conditional validation:
z.discriminatedUnion('type', [...])khi schema thay đổi tùy giá trị field khác
Kết luận
Sau cái đêm 2 giờ sáng đó, mình migrate toàn bộ form validation trong dự án sang React Hook Form + Zod trong vòng 3 ngày. Không phải vì deadline ép — mà vì một khi đã dùng, không thể chịu được cách cũ nữa.
Schema là single source of truth. TypeScript bắt lỗi từ compile time — không phải 2 giờ sáng trên production. Lỗi từ API gán thẳng vào đúng field trên form, user biết ngay mình sai ở đâu. Ba điều này thôi đã đủ để không bao giờ quay lại cách cũ.
Nếu bạn đang dùng validation kiểu cũ, thử migrate 1 form nhỏ nhất trong dự án theo cách này. Khả năng cao là sẽ không dừng lại ở 1 form đó đâu.
