Validasi Antar Field Dengan Zod Dan React Hook Form
8 min read

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 sekaligus

discriminatedUnion() — 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 --> F

Integrasi 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 jika companyName ditampilkan 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() atau discriminatedUnion.

Perbandingan Pendekatan

KebutuhanPendekatanAlasan
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 enumdiscriminatedUnion()Schema terpisah per kasus, tidak ada workaround
Form sangat dinamis + banyak opsidiscriminatedUnion() + useWatchSkalabel 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 — gunakan ctx.addIssue() untuk menambah error ke field mana saja secara bersamaan.
  • path wajib ada di setiap .refine() dan ctx.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.
  • useWatch untuk membuat komponen reaktif terhadap nilai field tertentu tanpa menyebabkan seluruh form re-render.

Portofolio