kodingup.camp

Beranda/Various Library Mastery

Validasi Input dan Manajemen Error dengan Zod

Modul 11 dari 134 menit bacaInput Validation & Error Management

Isi modul

    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/resolvers saat ini bisa gagal dengan error ERESOLVE karena 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 .npmrc di root proyek berisi legacy-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 .npmrc tidak 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).

    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:

    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:

    1. Tulis pesan sendiri di setiap aturan (paling jelas dan ramah). Contoh di atas memakai cara ini.
    2. 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

    Latihan

    1. Mudah, Buat skema loginSchema dengan email (format email) dan password (minimal 6 karakter), lengkap dengan pesan bahasa Indonesia. Uji dengan safeParse di console.
    2. Sedang, Buat skema tugas dengan deadline berupa string tanggal ("2026-12-31" dari input type="date") yang tidak boleh di masa lalu. Gunakan refine.
    3. Menantang, Hubungkan loginSchema ke 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

    Kuis pemahaman

    Pilih satu jawaban per soal. Penjelasan muncul setelah kamu menjawab.