Mục lục
Xem mục lục
- Vì sao dùng React Hook Form
- Setup cơ bản:
useForm+register - Vì sao cần schema validation thay vì validate thủ công
- Định nghĩa schema bằng Zod
- Kết nối schema vào React Hook Form bằng
zodResolver - Type-safe từ schema:
z.infer - Hiển thị lỗi validate
- Validate phụ thuộc field khác nhau
- So sánh nhanh Zod và Yup
- Validate mode: khi nào chạy validate
Controller: tích hợp component controlled từ bên ngoài- Tổng kết
Vì sao dùng React Hook Form
Quản lý form thủ công bằng useState cho từng field, rồi tự viết logic validate, disable submit, hiển thị lỗi, nhanh chóng phình to khi form có nhiều field:
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [emailError, setEmailError] = useState("");
const [passwordError, setPasswordError] = useState("");
// ... nhân đôi số state mỗi khi thêm field mới
Mỗi field controlled kiểu này gây một lần re-render mỗi ký tự gõ, như đã nói ở bài Controlled/Uncontrolled Components. Với form nhỏ không đáng kể, nhưng form nhiều field dễ giật lag nếu không tối ưu thêm.
React Hook Form giải quyết cả hai vấn đề cùng lúc:
- Ít code hơn:
register("email")thay thế cho một cặpuseState+onChange+ input value, không cần khai báo state riêng cho từng field. - Hiệu năng tốt hơn: đăng ký input qua
ref(gần với uncontrolled), không re-render form mỗi lần gõ phím, chỉ re-render khi cần (submit, validate lỗi, hoặc field dùngwatch). - Có sẵn điểm cắm cho validate: option
resolvernhận bất kỳ schema validation nào (Zod, Yup…), không cần tự viết logic validate thủ công, đây là phần được nói kỹ ở các mục sau.
So với Formik, một thư viện quản lý form phổ biến khác, React Hook Form mặc định controlled ít field hơn (thiên về uncontrolled), nên thường re-render ít hơn trên form lớn, đồng thời API gọn hơn nhờ register thay vì phải khai báo Field/Formik component bao quanh từng input.
Setup cơ bản: useForm + register
React Hook Form đi theo hướng gần với uncontrolled component: register gắn ref vào input thay vì quản lý qua value/onChange, tránh re-render theo từng ký tự gõ.
import { useForm } from "react-hook-form";
function LoginForm() {
const { register, handleSubmit } = useForm();
function onSubmit(data) {
console.log(data);
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("email")} />
<input type="password" {...register("password")} />
<button type="submit">Đăng nhập</button>
</form>
);
}
Ở bước này chưa có rule validate nào cả, handleSubmit(onSubmit) luôn gọi onSubmit bất kể dữ liệu hợp lệ hay không.
Vì sao cần schema validation thay vì validate thủ công
Validate thủ công thường rải rác if-else ngay trong onSubmit:
function handleSubmit(data) {
if (!data.email) return setError("Email bắt buộc");
if (!data.email.includes("@")) return setError("Email không hợp lệ");
if (data.password.length < 8) return setError("Mật khẩu tối thiểu 8 ký tự");
// ...
}
Cách này khó maintain khi form lớn dần: rule nằm rải rác, khó biết field nào có rule gì nếu không đọc hết hàm. Rule cũng không tái sử dụng được, cùng một field email xuất hiện ở nhiều form khác nhau lại phải viết lại từ đầu, và không chia sẻ được giữa validate ở frontend với validate ở backend nếu backend cũng cần đúng rule đó.
Schema validation giải quyết việc này bằng cách khai báo toàn bộ rule của một object dữ liệu ở một chỗ duy nhất, dưới dạng một schema, rồi dùng schema đó để validate bất kỳ đâu cần.
Định nghĩa schema bằng Zod
Zod khai báo rule validate dưới dạng schema, tách hẳn khỏi component:
import { z } from "zod";
export const loginSchema = z.object({
email: z.string().min(1, "Email bắt buộc").email("Email không hợp lệ"),
password: z.string().min(8, "Mật khẩu tối thiểu 8 ký tự"),
});login-schema.ts
z.object({...}) mô tả cấu trúc dữ liệu mong đợi, mỗi field đi kèm chuỗi các rule (min, email…), mỗi rule có thể gắn thông báo lỗi riêng. Schema này độc lập với React, có thể tái sử dụng ở bất kỳ đâu cần validate cùng một cấu trúc dữ liệu, kể cả ở backend (Node.js) nếu dùng chung Zod.
Kết nối schema vào React Hook Form bằng zodResolver
@hookform/resolvers/zod cung cấp zodResolver, cầu nối để React Hook Form dùng schema Zod làm nguồn validate thay vì tự viết rule:
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { loginSchema } from "./login-schema";
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm({
resolver: zodResolver(loginSchema),
});
function onSubmit(data) {
console.log(data); // data đã được validate đúng theo loginSchema
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("email")} />
{errors.email && <p>{errors.email.message}</p>}
<input type="password" {...register("password")} />
{errors.password && <p>{errors.password.message}</p>}
<button type="submit">Đăng nhập</button>
</form>
);
}
Từ đây, onSubmit chỉ chạy khi dữ liệu đã pass toàn bộ rule trong loginSchema. Nếu có field không hợp lệ, handleSubmit tự chặn submit, đưa lỗi vào formState.errors, không cần viết logic if-else nào trong component.
Type-safe từ schema: z.infer
Không dùng Zod, type của form data phải viết tay, dễ lệch với rule validate thật khi một trong hai bên thay đổi mà quên cập nhật bên còn lại. z.infer sinh type TypeScript trực tiếp từ schema, đảm bảo type luôn khớp đúng rule:
import { z } from "zod";
export const loginSchema = z.object({
email: z.string().min(1).email(),
password: z.string().min(8),
});
export type LoginFormData = z.infer<typeof loginSchema>;
// tương đương: { email: string; password: string }
const { register, handleSubmit } = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
});
function onSubmit(data: LoginFormData) {
// data.email, data.password đã có type chính xác, tự động đồng bộ với schema
}
Thêm field mới vào loginSchema, LoginFormData cập nhật type theo ngay, không cần sửa type ở chỗ khác.
Hiển thị lỗi validate
formState.errors là object có cùng cấu trúc với form data, mỗi field lỗi chứa message (thông báo đã khai báo trong schema) và type (tên rule bị vi phạm):
{
errors.email && (
<p className="text-sm text-red-500">{errors.email.message}</p>
);
}
Với form lồng nhiều cấp (object trong object), errors cũng lồng theo đúng cấu trúc đó, ví dụ errors.address?.city?.message.
Validate phụ thuộc field khác nhau
Một số rule không thể validate độc lập từng field, mà phụ thuộc vào field khác, ví dụ xác nhận mật khẩu phải khớp mật khẩu. Zod xử lý bằng .refine():
export const signupSchema = z
.object({
password: z.string().min(8, "Mật khẩu tối thiểu 8 ký tự"),
confirmPassword: z.string(),
})
.refine(data => data.password === data.confirmPassword, {
message: "Mật khẩu xác nhận không khớp",
path: ["confirmPassword"], // gắn lỗi vào đúng field confirmPassword
});
path chỉ định lỗi thuộc về field nào trong formState.errors, nếu không có path, lỗi sẽ gắn vào toàn bộ form thay vì một field cụ thể, khó hiển thị đúng chỗ trên UI.
Với rule phức tạp hơn, cần nhiều điều kiện hoặc validate bất đồng bộ (kiểm tra email đã tồn tại qua API), dùng .superRefine(), cho phép thêm nhiều lỗi vào nhiều field khác nhau trong cùng một lần chạy.
So sánh nhanh Zod và Yup
Cả hai đều được @hookform/resolvers hỗ trợ, khác nhau chủ yếu ở cách khai báo và mức độ gắn với TypeScript:
| Tiêu chí | Zod | Yup |
|---|---|---|
| Cú pháp | z.string().min(8) | yup.string().min(8) |
| Sinh type TypeScript | Có sẵn (z.infer), tự động, luôn đồng bộ | Cần cài thêm hoặc viết type riêng, dễ lệch schema |
| Thiết kế | TypeScript-first, type là phần cốt lõi | JavaScript-first, TypeScript hỗ trợ sau |
| Độ phổ biến | Tăng nhanh, phổ biến trong dự án TypeScript mới | Lâu đời hơn, hệ sinh thái tích hợp rộng (Formik…) |
Với dự án TypeScript, Zod thường được ưu tiên vì z.infer loại bỏ hẳn việc định nghĩa type tay. Với dự án JavaScript thuần hoặc đã có sẵn hệ thống validate bằng Yup (ví dụ dùng chung với Formik), giữ Yup để tránh viết lại toàn bộ schema là lựa chọn hợp lý hơn.
Validate mode: khi nào chạy validate
React Hook Form cho phép cấu hình thời điểm chạy validate qua option mode:
useForm({
resolver: zodResolver(loginSchema),
mode: "onBlur", // "onSubmit" | "onChange" | "onBlur" | "onTouched" | "all"
});
onSubmit(mặc định): chỉ validate khi bấm submit. Ít gây phiền nhất trong lúc gõ, nhưng người dùng chỉ biết lỗi sau khi đã điền xong toàn bộ form.onChange: validate mỗi khi giá trị field thay đổi. Phản hồi tức thời, nhưng dễ gây khó chịu vì báo lỗi ngay cả khi người dùng chưa gõ xong (ví dụ báo “email không hợp lệ” ngay khi mới gõ được vài ký tự).onBlur: validate khi field mất focus (rời khỏi input). Cân bằng giữa hai cách trên, phổ biến nhất trong thực tế: không làm phiền lúc đang gõ, nhưng vẫn báo lỗi sớm hơn so với đợi tới lúc submit.onTouched: validate khi field mất focus lần đầu, sau đó chuyển sang validate theoonChangecho field đó. Phù hợp khi muốn phản hồi nhanh sau lần lỗi đầu tiên, nhưng không làm phiền trước khi người dùng chạm vào field.
Controller: tích hợp component controlled từ bên ngoài
register dựa trên ref, chỉ hoạt động tốt với input HTML thuần hoặc component tự expose ref xuống đúng DOM node. Với các controlled component từ thư viện ngoài (MUI, Ant Design, react-select, DatePicker…) không cho truy cập trực tiếp ref tới input thật, register không gắn được, vì không có gì để ref trỏ vào.
React Hook Form cung cấp Controller cho tình huống này, dùng render prop để nối field.value/field.onChange vào props của component ngoài, thay vì dựa vào ref:
import { Controller } from "react-hook-form";
import Select from "react-select";
<Controller
name="country"
control={control}
render={({ field }) => (
<Select
options={countryOptions}
value={countryOptions.find(o => o.value === field.value)}
onChange={option => field.onChange(option.value)}
/>
)}
/>
Controller tự đồng bộ giá trị hai chiều: field.value luôn phản ánh đúng giá trị hiện tại trong React Hook Form, gọi field.onChange bên trong sự kiện của component ngoài để báo giá trị mới về form. Bản chất đây chính là cách tiếp cận controlled đã nói ở bài Controlled/Uncontrolled Components: register thiên về uncontrolled (dựa ref), còn Controller là cầu nối bắt buộc phải dùng controlled khi component ngoài không cho truy cập ref tới input thật.
Quy tắc chọn: ưu tiên register cho input HTML thuần hoặc component tự forward ref đúng cách, chỉ chuyển sang Controller khi thực sự cần tích hợp một component ngoài không hỗ trợ ref.
Tổng kết
- Schema validation tách rule ra khỏi component, khai báo một chỗ, tái sử dụng được ở nhiều form hoặc cả backend.
- React Hook Form kết nối với Zod qua
zodResolver, tự động chặn submit và điền lỗi vàoformState.errorskhi dữ liệu không hợp lệ. z.infersinh type TypeScript trực tiếp từ schema, đảm bảo type form data luôn đồng bộ với rule validate.- Rule phụ thuộc nhiều field dùng
.refine()/.superRefine(), nhớ gắnpathđể lỗi hiển thị đúng field. - Zod phù hợp hơn cho dự án TypeScript nhờ sinh type tự động, Yup vẫn hợp lý khi đã có sẵn hệ sinh thái dùng nó.
- Chọn
modephù hợp:onSubmitít phiền nhất,onChangephản hồi nhanh nhất nhưng dễ gây khó chịu,onBlurlà điểm cân bằng phổ biến nhất trong thực tế.