Kenapa ini penting
Aturan validasi bawaan React Hook Form cukup untuk form kecil. Tapi begitu aturannya rumit, "password dan konfirmasi harus sama", "tanggal tenggat tidak boleh di masa lalu", "username belum dipakai", aturan yang tersebar di setiap register jadi sulit dibaca dan sulit dipakai ulang. Lebih parah lagi, aturan yang sama sering perlu dipakai di tempat lain (misalnya memvalidasi data dari API).
Zod memungkinkan kamu menulis aturan sebagai skema: satu object yang menggambarkan bentuk data yang valid. Skema itu bisa dipakai di form (lewat zodResolver dari @hookform/resolvers), untuk memvalidasi respons API, bahkan di backend Node.js. Versi yang dipakai: zod 4.6.7 dan @hookform/resolvers 6.0.0. Rilis 6.0.0 resolvers hanya mengubah resolver untuk library Effect; cara memakai zodResolver tetap sama dan mendukung Zod 3.25+ maupun Zod 4.
Catatan instalasi (dicek 23 Oktober 2026):
npm install @hookform/resolverssaat ini bisa gagal dengan errorERESOLVEkarena konflik peer dependency opsional milik paket itu sendiri (dilaporkan di issue GitHub react-hook-form/resolvers #882, masih terbuka). Kode kamu tidak salah. Solusi sementara yang aman: buat file.npmrcdi root proyek berisilegacy-peer-deps=true, lalu install ulang. File ini ikut di-commit supaya Vercel/Netlify juga memakainya. Kalau issue itu sudah ditutup saat kamu membaca ini, file.npmrctidak diperlukan.
Konsep inti
Analogi: cetakan kue
Skema Zod itu seperti cetakan kue. Kamu menekan adonan (data) ke cetakan; kalau bentuknya pas, kamu dapat kue yang rapi (data tervalidasi, bahkan sudah dirapikan, misalnya spasi di ujung dibuang). Kalau tidak pas, cetakan memberi tahu bagian mana yang meluber dan kenapa (daftar error per field).
Menulis skema
import { z } from "zod";
const taskSchema = z.object({
title: z.string().trim().min(3, "Judul minimal 3 karakter").max(80, "Judul maksimal 80 karakter"),
priority: z.enum(["rendah", "sedang", "tinggi"], { error: "Pilih prioritas yang tersedia" }),
estimasi: z.number({ error: "Estimasi wajib berupa angka" }).int("Harus bilangan bulat").min(1, "Minimal 1 jam"),
email: z.email({ error: "Format email tidak valid" }),
catatan: z.string().max(200, "Catatan maksimal 200 karakter").optional(),
});
Beberapa hal tentang Zod 4:
- Pesan error bisa ditulis sebagai string langsung (
.min(3, "pesan")) atau object{ error: "pesan" }. Parametermessagedari Zod 3 masih diterima tetapi sudah ditandai usang; gunakanerror. - Format string seperti email kini tersedia sebagai fungsi tingkat atas:
z.email(),z.url(),z.uuid(). Bentuk lamaz.string().email()masih jalan tapi usang. z.infer<typeof schema>(di TypeScript) menghasilkan tipe data dari skema, jadi tipe dan validasi tidak perlu ditulis dua kali.
parse vs safeParse
taskSchema.parse(data); // valid → data bersih; tidak valid → melempar ZodError
taskSchema.safeParse(data); // tidak melempar; → { success: true, data } atau { success: false, error }
error.issues adalah array berisi { path, message, code }. Untuk tampilan per field, z.flattenError(error).fieldErrors mengubahnya menjadi { title: ["..."], email: ["..."] }.
Pesan error berbahasa Indonesia
Ada dua cara:
- Tulis pesan sendiri di setiap aturan (paling jelas dan ramah). Contoh di atas memakai cara ini.
- Locale bawaan Zod 4:
z.config(z.locales.id())mengubah pesan default menjadi bahasa Indonesia, misalnya"Terlalu kecil: diharapkan string memiliki >=5 karakter". Berguna sebagai cadangan untuk aturan yang tidak diberi pesan kustom. Panggil sekali di awal aplikasi.
Validasi lintas field dengan refine
const daftarSchema = z
.object({
password: z.string().min(8, "Password minimal 8 karakter"),
konfirmasi: z.string(),
})
.refine((d) => d.password === d.konfirmasi, {
error: "Konfirmasi password tidak sama",
path: ["konfirmasi"], // error ditempel ke field konfirmasi
});
Catatan perilaku: refine di level object dijalankan setelah pengecekan field. Kalau ada field yang tipenya salah (misalnya angka kosong menjadi NaN), refine level object tidak dijalankan dulu; pesannya baru muncul setelah error tipe itu diperbaiki.
Validasi async
refine bisa menerima fungsi async, misalnya mengecek ke server apakah username sudah dipakai. Skema dengan refine async wajib divalidasi dengan parseAsync/safeParseAsync; memakai parse biasa akan melempar error "Encountered Promise during synchronous parse". Kabar baiknya, zodResolver secara default memakai mode async, jadi aman dipakai di form.
Karena validasi bisa berjalan berkali-kali (tergantung mode form), validasi async yang memanggil server sebaiknya dipakai dengan mode: "onBlur" atau "onSubmit", bukan "onChange", supaya tidak mengirim request di setiap ketikan.
Menghubungkan ke React Hook Form
# solusi sementara untuk ERESOLVE (lihat catatan di atas)
echo "legacy-peer-deps=true" > .npmrc
npm install zod @hookform/resolvers
import { zodResolver } from "@hookform/resolvers/zod";
useForm({ resolver: zodResolver(taskSchema), defaultValues: {...} });
Dengan resolver, kamu tidak lagi menulis aturan di register, cukup {...register("title")}. Data di onValid adalah hasil dari skema (sudah di-trim, sudah dikonversi).
Error dari server
Validasi di frontend hanya untuk kenyamanan user; server tetap harus memvalidasi ulang. Kalau server menolak (misalnya 422 "judul sudah ada"), tampilkan dengan setError:
setError("title", { message: "Judul ini sudah dipakai" });
setError("root.server", { message: "Gagal menyimpan, coba lagi" }); // error umum form
Error root tidak terikat ke field mana pun; baca dengan errors.root?.server?.message.
Contoh kode
// src/features/auth/registerSchema.js
import { z } from "zod";
// cadangan: pesan default Zod dalam bahasa Indonesia
z.config(z.locales.id());
async function usernameTersedia(username) {
// jsonplaceholder punya user dengan username "Bret", "Antonette", dst.
const res = await fetch(
`https://jsonplaceholder.typicode.com/users?username=${encodeURIComponent(username)}`
);
const users = await res.json();
return users.length === 0;
}
export const registerSchema = z
.object({
nama: z.string().trim().min(2, "Nama minimal 2 karakter"),
username: z
.string()
.trim()
.min(3, "Username minimal 3 karakter")
.regex(/^[a-zA-Z0-9_]+$/, "Hanya huruf, angka, dan garis bawah")
.refine(usernameTersedia, { error: "Username sudah dipakai" }),
email: z.email({ error: "Format email tidak valid" }),
umur: z.number({ error: "Umur wajib diisi angka" }).int().min(17, "Minimal 17 tahun"),
password: z
.string()
.min(8, "Password minimal 8 karakter")
.regex(/\d/, "Password harus mengandung angka"),
konfirmasi: z.string(),
})
.refine((d) => d.password === d.konfirmasi, {
error: "Konfirmasi password tidak sama",
path: ["konfirmasi"],
});
// src/features/auth/RegisterForm.jsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { registerSchema } from "./registerSchema";
function Field({ label, error, children }) {
return (
<div>
<label className="block text-sm font-medium">{label}</label>
{children}
{error && <p className="mt-1 text-sm text-red-600">{error.message}</p>}
</div>
);
}
export default function RegisterForm() {
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm({
resolver: zodResolver(registerSchema),
mode: "onBlur", // validasi async (cek username) hanya saat blur, bukan tiap ketikan
defaultValues: { nama: "", username: "", email: "", umur: undefined, password: "", konfirmasi: "" },
});
async function onValid(data) {
try {
// data sudah bersih: nama & username sudah di-trim, umur berupa number
const res = await fetch("https://jsonplaceholder.typicode.com/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error();
alert(`Akun ${data.username} berhasil dibuat`);
} catch {
setError("root.server", { message: "Server sedang bermasalah. Coba lagi nanti." });
}
}
const inputClass = "w-full rounded border px-3 py-2";
return (
<form onSubmit={handleSubmit(onValid)} noValidate className="mx-auto max-w-md space-y-3 p-6">
<Field label="Nama" error={errors.nama}>
<input className={inputClass} {...register("nama")} />
</Field>
<Field label="Username" error={errors.username}>
<input className={inputClass} {...register("username")} />
</Field>
<Field label="Email" error={errors.email}>
<input type="email" className={inputClass} {...register("email")} />
</Field>
<Field label="Umur" error={errors.umur}>
<input type="number" className={inputClass} {...register("umur", { valueAsNumber: true })} />
</Field>
<Field label="Password" error={errors.password}>
<input type="password" className={inputClass} {...register("password")} />
</Field>
<Field label="Konfirmasi password" error={errors.konfirmasi}>
<input type="password" className={inputClass} {...register("konfirmasi")} />
</Field>
{errors.root?.server && <p className="text-sm text-red-600">{errors.root.server.message}</p>}
<button
type="submit"
disabled={isSubmitting}
className="w-full rounded bg-slate-900 py-2 text-white disabled:opacity-50"
>
{isSubmitting ? "Memeriksa..." : "Daftar"}
</button>
</form>
);
}
// → username "Bret" → "Username sudah dipakai"
// → email "budi@" → "Format email tidak valid"
// → umur dikosongkan → "Umur wajib diisi angka"
// → konfirmasi berbeda → "Konfirmasi password tidak sama"
Kesalahan umum
- Masih menulis aturan di
registersetelah memakai resolver. Aturan diregisterdiabaikan ketika resolver aktif. Taruh semua aturan di skema. - Memakai
parsepada skema dengan refine async. Akan melempar error Promise. GunakanparseAsync/safeParseAsync(zodResolver sudah otomatis async). - Lupa
pathdi refine level object. Tanpapath, error menempel ke root form dan tidak muncul di bawah field yang dimaksud. - Input angka tanpa
valueAsNumber. Nilai input selalu string;z.number()akan menolak"25". PakaivalueAsNumber: trueatauz.coerce.number(). - Hanya memvalidasi di frontend. Validasi frontend bisa dilewati (misalnya lewat Postman). Server tetap wajib memvalidasi; tampilkan penolakan server dengan
setError.
Latihan
- Mudah, Buat skema
loginSchemadenganemail(format email) danpassword(minimal 6 karakter), lengkap dengan pesan bahasa Indonesia. Uji dengansafeParsedi console. - Sedang, Buat skema tugas dengan
deadlineberupa string tanggal ("2026-12-31"dari inputtype="date") yang tidak boleh di masa lalu. Gunakanrefine. - Menantang, Hubungkan
loginSchemake form RHF. Kalau server membalas "password salah", tampilkan di bawah field password; kalau server error umum, tampilkan di atas tombol.
Lihat solusi
// 1.
import { z } from "zod";
const loginSchema = z.object({
email: z.email({ error: "Format email tidak valid" }),
password: z.string().min(6, "Password minimal 6 karakter"),
});
const hasil = loginSchema.safeParse({ email: "budi", password: "123" });
console.log(hasil.success); // → false
console.log(z.flattenError(hasil.error).fieldErrors);
// → { email: ["Format email tidak valid"], password: ["Password minimal 6 karakter"] }
// 2.
const hariIni = () => new Date().toISOString().slice(0, 10); // "YYYY-MM-DD"
const taskSchema = z.object({
title: z.string().trim().min(3, "Judul minimal 3 karakter"),
deadline: z
.string()
.min(1, "Tanggal tenggat wajib diisi")
.refine((tgl) => tgl >= hariIni(), { error: "Tanggal tenggat tidak boleh di masa lalu" }),
});
// string "YYYY-MM-DD" bisa dibandingkan langsung secara alfabetis
// catatan: toISOString memakai UTC, jadi di sekitar tengah malam WIB tanggalnya bisa beda satu hari
// 3.
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
export function LoginForm({ login }) {
const { register, handleSubmit, setError, formState: { errors, isSubmitting } } = useForm({
resolver: zodResolver(loginSchema),
defaultValues: { email: "", password: "" },
});
async function onValid(data) {
try {
await login(data); // misalnya memanggil api.post("/login", data)
} catch (err) {
if (err.response?.status === 401) {
setError("password", { message: "Password salah" });
} else {
setError("root.server", { message: "Gagal masuk, coba lagi nanti" });
}
}
}
return (
<form onSubmit={handleSubmit(onValid)} noValidate>
<input {...register("email")} />
{errors.email && <p>{errors.email.message}</p>}
<input type="password" {...register("password")} />
{errors.password && <p>{errors.password.message}</p>}
{errors.root?.server && <p>{errors.root.server.message}</p>}
<button disabled={isSubmitting}>Masuk</button>
</form>
);
}
Ringkasan
- Zod mendefinisikan bentuk data valid sebagai skema yang bisa dipakai ulang di form, API, dan backend.
- Zod 4: pesan lewat string langsung atau
{ error }; format sepertiz.email()di tingkat atas;z.config(z.locales.id())untuk pesan default bahasa Indonesia. safeParsetidak melempar;refine+pathuntuk validasi lintas field; refine async butuhparseAsync.zodResolver(schema)dari@hookform/resolvers/zodmenghubungkan skema ke RHF; aturan diregistertidak dipakai lagi.- Validasi frontend untuk kenyamanan; server tetap memvalidasi, dan errornya ditampilkan dengan
setError.