Validasi Antar Field Dengan Zod Dan React Hook Form
Validasi form jarang berdiri sendiri pada satu field. Hampir setiap form produksi punya aturan yang melibatkan lebih dari satu field sekaligus — konfirmasi password harus cocok dengan password, end date harus lebih besar dari start date, atau nama perusahaan hanya wajib diisi jika user memilih tipe akun bisnis. Pola-pola ini disebut cross-field validation, dan ini adalah salah satu bagian validasi yang paling sering salah diimplementasikan. Artikel ini membahas secara menyeluruh cara menangani cross-field validation menggunakan Zod dan React Hook Form, mulai dari kasus sederhana hingga arsitektur schema yang scalable untuk form kompleks.
Apa Itu Cross-Field Validation?
Cross-field validation adalah aturan validasi yang tidak bisa ditentukan hanya dari nilai satu field — ia membutuhkan nilai dari field lain untuk memutuskan valid atau tidaknya sebuah input.
Validasi biasa:
password → minimal 8 karakter (hanya butuh nilai password)
Cross-field validation:
confirmPassword → harus sama dengan password (butuh nilai dua field)
endDate → harus lebih besar dari startDate (butuh nilai dua field)
companyName → wajib jika isCompany = true (nilai bergantung state field lain)
Zod menyediakan tiga mekanisme untuk menangani ini, masing-masing cocok untuk situasi yang berbeda:
flowchart TD
A{Berapa kondisi\nyang perlu dicek?} -- Satu kondisi --> B{Perlu multiple\nerror sekaligus?}
B -- Tidak --> C[".refine()"]
B -- Ya --> D[".superRefine()"]
A -- Banyak kondisi --> E{Struktur form\nberbeda per nilai?}
E -- Ya --> F["discriminatedUnion()"]
E -- Tidak --> D.refine() — Validasi Satu Kondisi
Gunakan .refine() ketika hanya ada satu aturan cross-field yang perlu dicek dan hanya menghasilkan satu pesan error.
Konfirmasi Password
Ini adalah kasus paling umum: field confirmPassword harus identik dengan password.
// ANTI-PATTERN: validasi dilakukan di komponen, schema tidak tahu apa-apa
const onSubmit = (data) => {
if (data.password !== data.confirmPassword) {
setError("confirmPassword", { message: "Password tidak sama" });
return;
}
// proses submit...
};
// BENAR: validasi berada di schema, komponen tetap bersih
import { z } from "zod";
const loginSchema = z
.object({
password: z.string().min(8, "Minimal 8 karakter"),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Konfirmasi password tidak cocok",
path: ["confirmPassword"],
});
Parameter path adalah kunci di sini. Tanpanya, error akan muncul di level root form dan React Hook Form tidak tahu field mana yang harus ditandai merah. Dengan path: ["confirmPassword"], error diarahkan tepat ke field yang dimaksud.
Validasi Rentang Tanggal
Pola yang sama berlaku untuk date range — error diarahkan ke field “kedua” yang dianggap salah:
const bookingSchema = z
.object({
startDate: z.date({ required_error: "Tanggal mulai wajib diisi" }),
endDate: z.date({ required_error: "Tanggal selesai wajib diisi" }),
})
.refine((data) => data.endDate > data.startDate, {
message: "Tanggal selesai harus setelah tanggal mulai",
path: ["endDate"],
});
.refine()hanya menghasilkan satu error per validasi. Jika kamu perlu menampilkan beberapa error sekaligus dari satu validasi cross-field,.refine()tidak cukup — gunakan.superRefine()sebagai gantinya.
.superRefine() — Kontrol Penuh
Gunakan .superRefine() ketika ada banyak kondisi yang perlu dicek, atau ketika validasi perlu menghasilkan beberapa error pada field yang berbeda secara bersamaan.
Field Conditional Required
Skenario: companyName hanya wajib diisi jika isCompany bernilai true.
// ANTI-PATTERN: companyName selalu required di schema
const schema = z.object({
isCompany: z.boolean(),
companyName: z.string().min(1), // selalu gagal jika kosong, padahal tidak selalu perlu
});
// BENAR: companyName optional di schema, validasi conditional di superRefine
const registrationSchema = z
.object({
isCompany: z.boolean(),
companyName: z.string().optional(),
taxId: z.string().optional(),
})
.superRefine((data, ctx) => {
if (data.isCompany) {
if (!data.companyName) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Nama perusahaan wajib diisi untuk akun bisnis",
path: ["companyName"],
});
}
if (!data.taxId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "NPWP wajib diisi untuk akun bisnis",
path: ["taxId"],
});
}
}
});
Dengan .superRefine(), kedua error — companyName dan taxId — bisa muncul bersamaan. User tidak perlu submit dua kali hanya untuk menemukan semua field yang kurang.
Validasi Password Kompleks
.superRefine() juga berguna saat validasi satu field memiliki banyak aturan yang saling terkait:
const passwordChangeSchema = z
.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(8),
confirmPassword: z.string(),
})
.superRefine((data, ctx) => {
// Pastikan password baru berbeda dari password lama
if (data.newPassword === data.currentPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Password baru tidak boleh sama dengan password lama",
path: ["newPassword"],
});
}
// Pastikan konfirmasi cocok
if (data.newPassword !== data.confirmPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Konfirmasi password tidak cocok",
path: ["confirmPassword"],
});
}
});
sequenceDiagram
participant User
participant RHF as React Hook Form
participant Zod as Zod Schema
participant UI
User->>RHF: submit form
RHF->>Zod: validasi semua field
Zod->>Zod: jalankan superRefine
Zod-->>RHF: array ZodIssue (bisa > 1)
RHF->>RHF: distribusikan error ke field masing-masing
RHF-->>UI: render error di confirmPassword & newPassword
UI-->>User: tampilkan semua error sekaligusdiscriminatedUnion() — Arsitektur Scalable
Ketika satu field menentukan struktur form secara keseluruhan, .refine() dan .superRefine() mulai terasa seperti workaround. Solusinya adalah z.discriminatedUnion() — sebuah union type di mana satu field bertindak sebagai discriminator yang menentukan shape schema yang berlaku.
Kasus: Metode Pembayaran
// ANTI-PATTERN: satu schema besar dengan semua field optional
const schema = z.object({
paymentMethod: z.enum(["credit_card", "bank_transfer", "ewallet"]),
cardNumber: z.string().optional(), // hanya untuk credit_card
bankAccount: z.string().optional(), // hanya untuk bank_transfer
ewalletPhone: z.string().optional(), // hanya untuk ewallet
}).superRefine((data, ctx) => {
// conditional logic bertambah seiring bertambahnya metode pembayaran
if (data.paymentMethod === "credit_card" && !data.cardNumber) {
ctx.addIssue({ ... });
}
// ... dan seterusnya, makin panjang makin sulit di-maintain
});
// BENAR: discriminatedUnion — setiap metode punya schema sendiri
const paymentSchema = z.discriminatedUnion("paymentMethod", [
z.object({
paymentMethod: z.literal("credit_card"),
cardNumber: z.string().min(16, "Nomor kartu tidak valid").max(16),
cardExpiry: z.string().regex(/^\d{2}\/\d{2}$/, "Format MM/YY"),
cardCvv: z.string().length(3, "CVV harus 3 digit"),
}),
z.object({
paymentMethod: z.literal("bank_transfer"),
bankCode: z.string().min(1, "Pilih bank tujuan"),
bankAccount: z.string().min(10, "Nomor rekening tidak valid"),
accountName: z.string().min(1, "Nama pemilik rekening wajib diisi"),
}),
z.object({
paymentMethod: z.literal("ewallet"),
ewalletProvider: z.enum(["gopay", "ovo", "dana"]),
ewalletPhone: z.string().regex(/^08\d{8,11}$/, "Format nomor HP tidak valid"),
}),
]);
Setiap kali paymentMethod berubah, Zod otomatis tahu schema mana yang harus dipakai untuk validasi. Tidak ada optional() workaround, tidak ada conditional logic manual yang makin panjang seiring bertambahnya opsi.
flowchart TD
A[paymentMethod] --> B{Nilai discriminator}
B -- "credit_card" --> C["Schema: cardNumber\ncardExpiry, cardCvv"]
B -- "bank_transfer" --> D["Schema: bankCode\nbankAccount, accountName"]
B -- "ewallet" --> E["Schema: ewalletProvider\newalletPhone"]
C --> F[Validasi berjalan\nsesuai schema yang aktif]
D --> F
E --> FIntegrasi dengan React Hook Form
Zod terhubung ke React Hook Form melalui zodResolver dari paket @hookform/resolvers:
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const schema = z
.object({
password: z.string().min(8),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Konfirmasi password tidak cocok",
path: ["confirmPassword"],
});
type FormValues = z.infer<typeof schema>;
function ChangePasswordForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
mode: "onChange", // validasi berjalan setiap perubahan nilai
});
const onSubmit = (data: FormValues) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("password")} type="password" />
{errors.password && <p>{errors.password.message}</p>}
<input {...register("confirmPassword")} type="password" />
{errors.confirmPassword && <p>{errors.confirmPassword.message}</p>}
<button type="submit">Simpan</button>
</form>
);
}
Field Dinamis dan useWatch
Untuk form di mana tampilan field berubah berdasarkan nilai field lain, gunakan useWatch agar komponen reaktif terhadap perubahan:
import { useForm, useWatch } from "react-hook-form";
function RegistrationForm() {
const { register, control, handleSubmit } = useForm({
resolver: zodResolver(registrationSchema),
});
// komponen re-render hanya saat isCompany berubah
const isCompany = useWatch({ control, name: "isCompany" });
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("isCompany")} type="checkbox" />
{isCompany && (
<>
<input {...register("companyName")} placeholder="Nama Perusahaan" />
<input {...register("taxId")} placeholder="NPWP" />
</>
)}
<button type="submit">Daftar</button>
</form>
);
}
Field yang tidak dirender tetap divalidasi oleh Zod. Artinya jikacompanyNameditampilkan kondisional di UI tapi schema-nya tidak menggunakan.optional(), validasi akan tetap gagal meski field tidak terlihat. Selalu pastikan field conditional menggunakan.optional()di schema, lalu atur kewajibannya melalui.superRefine()ataudiscriminatedUnion.
Perbandingan Pendekatan
| Kebutuhan | Pendekatan | Alasan |
|---|---|---|
| Satu aturan cross-field sederhana | .refine() | Ringkas, cukup untuk satu kondisi |
| Banyak kondisi, bisa multiple error | .superRefine() | Kontrol penuh atas setiap issue |
| Struktur form berbeda per nilai enum | discriminatedUnion() | Schema terpisah per kasus, tidak ada workaround |
| Form sangat dinamis + banyak opsi | discriminatedUnion() + useWatch | Skalabel dan type-safe |
Anti-Pattern yang Harus Dihindari
// ✗ Error cross-field tanpa path — muncul di root, bukan di field
const schema = z.object({ ... }).refine(
(data) => data.endDate > data.startDate,
{ message: "Tanggal tidak valid" } // tidak ada path!
);
// ✓ Selalu sertakan path agar error muncul di field yang tepat
const schema = z.object({ ... }).refine(
(data) => data.endDate > data.startDate,
{ message: "Tanggal selesai harus setelah tanggal mulai", path: ["endDate"] }
);
// ✗ Validasi cross-field dilakukan di handler komponen
const onSubmit = (data) => {
if (data.password !== data.confirmPassword) {
setError("confirmPassword", ...); // logic tersebar di mana-mana
}
};
// ✓ Semua validasi berada di schema Zod
const schema = z.object({ ... }).refine(...);
// ✗ Field conditional tidak menggunakan .optional()
const schema = z.object({
isCompany: z.boolean(),
companyName: z.string().min(1), // selalu gagal saat isCompany = false
});
// ✓ optional di schema, required-nya diatur di superRefine
const schema = z.object({
isCompany: z.boolean(),
companyName: z.string().optional(),
}).superRefine((data, ctx) => {
if (data.isCompany && !data.companyName) {
ctx.addIssue({ ... });
}
});
// ✗ Gunakan superRefine untuk semua hal termasuk yang bisa pakai discriminatedUnion
// ✓ Gunakan discriminatedUnion saat satu field menentukan seluruh shape form
Checklist Implementasi Cross-Field Validation
SCHEMA:
□ Setiap .refine() memiliki parameter path yang jelas
□ Field conditional menggunakan .optional() di schema utama
□ Kebutuhan > 1 error sekaligus menggunakan .superRefine()
□ Form berbasis enum/pilihan menggunakan discriminatedUnion
INTEGRASI RHF:
□ zodResolver digunakan sebagai resolver di useForm
□ mode "onChange" atau "onBlur" disesuaikan dengan UX yang diinginkan
□ useWatch digunakan untuk field yang ditampilkan kondisional
□ errors dari formState ditampilkan di tiap field terkait
ARSITEKTUR:
□ Validasi berada di schema, bukan di komponen atau handler
□ Type form diambil dari z.infer<typeof schema>
□ Tidak ada manual setError kecuali untuk error dari server
Ringkasan
.refine()untuk satu aturan cross-field sederhana — ringkas dan mudah dibaca, tapi hanya menghasilkan satu error..superRefine()untuk validasi kompleks dengan banyak kondisi — gunakanctx.addIssue()untuk menambah error ke field mana saja secara bersamaan.pathwajib ada di setiap.refine()danctx.addIssue()agar React Hook Form tahu field mana yang harus ditandai error.- Field conditional harus
.optional()di schema utama — atur kewajibannya secara programatik di.superRefine().discriminatedUnion()adalah pilihan terbaik ketika satu field menentukan seluruh shape form — lebih bersih, type-safe, dan mudah dikembangkan.- Jangan validasi cross-field di komponen — logic yang tersebar di handler form susah di-maintain dan sulit ditest.
useWatchuntuk membuat komponen reaktif terhadap nilai field tertentu tanpa menyebabkan seluruh form re-render.