Skip to content
Home
Go back

Validate Form với React Hook Form và Zod

Mục lục

Xem mục lục

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:

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íZodYup
Cú phápz.string().min(8)yup.string().min(8)
Sinh type TypeScriptCó 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õiJavaScript-first, TypeScript hỗ trợ sau
Độ phổ biếnTăng nhanh, phổ biến trong dự án TypeScript mớiLâ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"
});

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


Share this post:

Previous Post
Controlled và Uncontrolled Components trong React
Next Post
Component Lifecycle trong React: Mounting, Updating, Unmounting