Validasi Form Frontend dengan Zod dan React-Hook-Form: Schema-Based Validation
Mengapa Schema-Based Validation Lebih Unggul Dibanding Validasi Manual
Setiap aplikasi web yang menangani input dari pengguna pasti membutuhkan validasi form. Pendekatan tradisional yang masih sering kita lihat adalah validasi manual: menulis kondisi if-else satu per satu untuk setiap field, menyimpan error dalam state terpisah, dan menulis ulang logika tipe data di berbagai tempat. Hasilnya? Kode yang berserakan, tipe data yang tidak terjamin konsistensinya, dan penanganan error yang inkonsisten antar form.
Schema-based validation hadir sebagai solusi untuk masalah ini. Konsepnya sederhana: kita mendefinisikan satu source of truth — sebuah schema — yang berisi semua aturan validasi dan struktur tipe data untuk sebuah form. Schema ini kemudian digunakan secara konsisten di seluruh aplikasi, baik untuk validasi di sisi klien maupun untuk inferensi tipe TypeScript.
Zod adalah schema declaration library yang mengimplementasikan konsep ini dengan sangat baik. Zod tidak hanya memvalidasi data, tetapi juga menginferensi tipe TypeScript secara otomatis. Artinya, kita tidak perlu mendefinisikan tipe data secara terpisah — cukup buat schema Zod, dan tipe TypeScript akan mengikuti secara otomatis.

Gambar: Ilustrasi pipeline validasi data — setiap tahap memvalidasi dan mentransformasi data sebelum mencapai output akhir — Sumber: [Pixexid](https://pixexid.com/i/pipeline-diagram-vector-illustration-23ebe724) (CC BY 4.0)
Di sisi lain, React-Hook-Form menangani masalah performa yang sering muncul pada form React. Dengan menggunakan uncontrolled components, React-Hook-Form menghindari re-render yang tidak perlu pada seluruh komponen form saat pengguna mengetik. Kombinasi Zod untuk validasi dan React-Hook-Form untuk manajemen state form menghasilkan pendekatan yang efisien, type-safe, dan mudah dipelihara.
Mendefinisikan Skema Validasi Form dengan Zod
Langkah pertama dalam schema-based validation adalah mendefinisikan schema menggunakan Zod. Kita mulai dengan z.object() untuk membuat schema yang merepresentasikan struktur data form.
import { z } from "zod";
export const registrationSchema = z
.object({
nama: z.string().min(3, "Nama minimal 3 karakter"),
email: z.string().email("Format email tidak valid"),
password: z.string().min(8, "Password minimal 8 karakter"),
umur: z
.number({ invalid_type_error: "Umur harus berupa angka" })
.min(17, "Minimal usia 17 tahun")
.max(65, "Maksimal usia 65 tahun"),
role: z.enum(["student", "professional", "other"], {
errorMap: () => ({ message: "Pilih role yang tersedia" }),
}),
konfirmasiPassword: z.string(),
})
.superRefine((data, ctx) => {
if (data.password !== data.konfirmasiPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["konfirmasiPassword"],
message: "Password dan konfirmasi password harus sama",
});
}
});
export type RegistrationForm = z.infer<typeof registrationSchema>;Output (hasil eksekusi schema Zod dengan berbagai skenario data):
===BLOCK_1_START===
╔══════════════════════════════════════════════════╗
║ Test Case 1: Data Valid (semua field benar) ║
╚══════════════════════════════════════════════════╝
Status: ✅ VALID
Data yang diterima: {
"nama": "Budi Santoso",
"email": "[email protected]",
"password": "rahasia123",
"umur": 25,
"role": "student",
"konfirmasiPassword": "rahasia123"
}
╔══════════════════════════════════════════════════╗
║ Test Case 2: Email tidak valid ║
╚══════════════════════════════════════════════════╝
Status: ❌ INVALID
• Field "nama": Nama minimal 3 karakter
• Field "email": Format email tidak valid
╔══════════════════════════════════════════════════╗
║ Test Case 3: Password tidak cocok ║
╚══════════════════════════════════════════════════╝
Status: ❌ INVALID
• Field "konfirmasiPassword": Password dan konfirmasi password harus sama
╔══════════════════════════════════════════════════╗
║ Test Case 4: Umur bukan angka & role salah ║
╚══════════════════════════════════════════════════╝
Status: ❌ INVALID
• Field "umur": Invalid input: expected number, received string
• Field "role": Invalid option: expected one of "student"|"professional"|"other"
╔══════════════════════════════════════════════════╗
║ Test Case 5: Umur di bawah minimum (16) ║
╚══════════════════════════════════════════════════╝
Status: ❌ INVALID
• Field "umur": Minimal usia 17 tahun
===BLOCK_1_END===Perhatikan beberapa method validasi bawaan Zod yang kita gunakan di sini. z.string().min() memvalidasi panjang minimum string, z.string().email() memvalidasi format email, z.number().min().max() membatasi rentang angka, dan z.enum() membatasi nilai ke opsi yang telah ditentukan.
Yang paling penting adalah superRefine. Method ini memungkinkan validasi lintas-field, yaitu aturan yang melibatkan lebih dari satu field. Dalam contoh di atas, kita membandingkan password dengan konfirmasiPassword dan menambahkan issue kustom jika keduanya tidak cocok.
Fitur yang sangat berguna adalah z.infer<typeof schema>. Dengan satu baris kode ini, TypeScript secara otomatis menginferensi tipe data form dari schema yang kita definisikan. Setiap kali kita mengubah schema, tipe data akan menyesuaikan secara otomatis tanpa perlu diedit manual.
Mengintegrasikan Zod Schema ke dalam React-Hook-Form
Setelah schema siap, langkah berikutnya adalah menghubungkannya ke React-Hook-Form. Kita membutuhkan paket @hookform/resolvers yang menyediakan zodResolver sebagai jembatan antara Zod dan React-Hook-Form.
Fullstack Web Development With Next.js
A practical, beginner-friendly, and project-based introduction to full-stack web...
npm install react-hook-form @hookform/resolvers zodimport { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { registrationSchema, RegistrationForm } from "./schemas/registration";
function RegistrationForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<RegistrationForm>({
resolver: zodResolver(registrationSchema),
defaultValues: {
nama: "",
email: "",
password: "",
konfirmasiPassword: "",
role: "student",
},
});
const onSubmit = async (data: RegistrationForm) => {
// Kirim data ke API
console.log("Form submitted:", data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="nama">Nama Lengkap</label>
<input id="nama" {...register("nama")} />
{errors.nama && <span>{errors.nama.message}</span>}
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" type="email" {...register("email")} />
{errors.email && <span>{errors.email.message}</span>}
</div>
<div>
<label htmlFor="password">Password</label>
<input id="password" type="password" {...register("password")} />
{errors.password && <span>{errors.password.message}</span>}
</div>
<div>
<label htmlFor="umur">Umur</label>
<input id="umur" type="number" {...register("umur", { valueAsNumber: true })} />
{errors.umur && <span>{errors.umur.message}</span>}
</div>
<div>
<label htmlFor="role">Role</label>
<select id="role" {...register("role")}>
<option value="student">Student</option>
<option value="professional">Professional</option>
<option value="other">Other</option>
</select>
{errors.role && <span>{errors.role.message}</span>}
</div>
<div>
<label htmlFor="konfirmasiPassword">Konfirmasi Password</label>
<input
id="konfirmasiPassword"
type="password"
{...register("konfirmasiPassword")}
/>
{errors.konfirmasiPassword && (
<span>{errors.konfirmasiPassword.message}</span>
)}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Mendaftarkan..." : "Daftar"}
</button>
</form>
);
}Dengan zodResolver, setiap kali pengguna menekan tombol submit, React-Hook-Form akan menjalankan validasi menggunakan schema Zod secara otomatis. Data dari form akan melewati validasi Zod terlebih dahulu sebelum sampai ke handler onSubmit. Jika ada field yang tidak valid, React-Hook-Form akan mengisi objek errors dengan pesan error yang sesuai.
Method register() dari React-Hook-Form adalah kunci dari efisiensi performa. Method ini mengembalikan props onChange, onBlur, ref, dan name yang langsung kita sebarkan ke elemen input HTML. Karena React-Hook-Form menggunakan uncontrolled components, perubahan nilai input tidak memicu re-render pada seluruh komponen form — hanya field yang sedang diinteraksikan oleh pengguna yang terpengaruh.

Gambar: Workflow form validation dari input pengguna, validasi per-field, hingga submit — setiap langkah memiliki peran dalam memastikan data yang dikirim valid dan aman — Sumber: [Pixexid](https://pixexid.com/i/operations-planning-table-workflow-diagram-1fe4a2ea) (CC BY 4.0)
Menampilkan Error Validation dan Memberikan Feedback Visual ke User
Validasi yang baik tidak lengkap tanpa feedback visual yang jelas bagi pengguna. React-Hook-Form menyediakan formState.errors yang berisi semua error validasi, dan kita bisa mengaksesnya untuk menampilkan pesan error per-field.
interface InputProps {
label: string;
name: keyof RegistrationForm;
type?: string;
register: UseFormRegister<RegistrationForm>;
error?: FieldError;
}
function FormInput({ label, name, type = "text", register, error }: InputProps) {
return (
<div className="form-group">
<label htmlFor={name}>{label}</label>
<input
id={name}
type={type}
className={error ? "input-error" : ""}
{...register(name)}
/>
{error && <span className="error-message">{error.message}</span>}
</div>
);
}
// Error summary di atas form
function ErrorSummary({ errors }: { errors: FieldErrors<RegistrationForm> }) {
const errorEntries = Object.entries(errors);
if (errorEntries.length === 0) return null;
return (
<div className="error-summary">
<strong>Ada {errorEntries.length} masalah pada form:</strong>
<ul>
{errorEntries.map(([field, err]) => (
<li key={field}>
{field}: {err?.message as string}
</li>
))}
</ul>
</div>
);
}Ada dua strategi dalam menampilkan error: inline dan summary. Error inline ditampilkan tepat di bawah masing-masing input, memberikan konteks langsung kepada pengguna tentang field mana yang bermasalah. Error summary ditempatkan di atas form, memberikan gambaran umum tentang jumlah dan jenis error yang perlu diperbaiki.
Kombinasi keduanya adalah pendekatan yang paling efektif. Error inline membantu pengguna menemukan field yang salah dengan cepat, sementara error summary memberikan orientasi awal bahwa form memiliki masalah yang perlu diselesaikan sebelum bisa dikirim.
Kita juga bisa memanfaatkan touchedFields dari React-Hook-Form untuk hanya menampilkan error setelah pengguna meninggalkan field (onBlur) atau setelah submit pertama kali. Pendekatan ini mencegah form menampilkan error terlalu dini — misalnya saat pengguna baru mulai mengetik di field pertama dan semua field lain masih kosong.
Validasi Lanjutan — Transformasi Data dan Validasi Asinkron
Schema-based validation tidak hanya sebatas validasi input. Zod juga menyediakan fitur transformasi data untuk membersihkan input sebelum data dikirim ke server, serta validasi asinkron untuk mengecek data ke backend.
import { z } from "zod";
async function checkEmailAvailability(email: string): Promise<boolean> {
const response = await fetch(`/api/check-email?email=${email}`);
const data = await response.json();
return data.available;
}
export const advancedRegistrationSchema = z
.object({
email: z
.string()
.email("Format email tidak valid")
.transform((email) => email.toLowerCase().trim()),
username: z
.string()
.min(3, "Username minimal 3 karakter")
.transform((name) => name.trim()),
password: z.string().min(8, "Password minimal 8 karakter"),
})
.superRefine(async (data, ctx) => {
const emailAvailable = await checkEmailAvailability(data.email);
if (!emailAvailable) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["email"],
message: "Email sudah digunakan",
});
}
});Method .transform() memungkinkan kita memodifikasi nilai input sebelum data lolos validasi. Dalam contoh di atas, email secara otomatis diubah menjadi lowercase dan username di-trim untuk menghilangkan spasi di awal dan akhir. Transformasi ini sangat berguna untuk membersihkan data input yang sering kali tidak konsisten dari pengguna.
Validasi asinkron menggunakan refine dengan fungsi async memungkinkan kita mengecek ketersediaan email atau username ke backend secara real-time. Ketika digunakan bersama React-Hook-Form, validasi asinkron dijalankan setiap kali pengguna mengubah nilai field yang relevan, memberikan feedback langsung sebelum pengguna menekan tombol submit.
Penting untuk diperhatikan bahwa validasi asinkron memiliki implikasi pada UX. Setiap kali pengguna mengetik, sebuah request akan dikirim ke server. Tanpa perlindungan, ini bisa membanjiri backend dengan request yang tidak perlu. Solusi yang umum digunakan adalah debouncing — menunda eksekusi validasi sampai pengguna berhenti mengetik selama beberapa milidetik. React-Hook-Form mendukung ini melalui konfigurasi useForm dengan mode: "onBlur" atau menggunakan library pihak ketiga seperti useDebounce.
Pola Organisasi Schema untuk Aplikasi Skala Besar
Saat aplikasi tumbuh, jumlah form dan schema akan bertambah. Tanpa organisasi yang baik, schema-schema ini akan tersebar tanpa struktur yang jelas. Berikut beberapa pola organisasi yang bisa kita terapkan.
Pisahkan schema ke dalam file terpisah berdasarkan domain. Misalnya, schemas/auth.ts untuk schema login dan registrasi, schemas/profile.ts untuk schema profil pengguna, dan schemas/product.ts untuk schema produk. Setiap file mengekspor schema beserta tipe hasil inferensi TypeScript.
Gunakan schema composition untuk menghindari duplikasi. Zod mendukung penggabungan schema melalui method .merge() dan .extend(). Contohnya, kita bisa membuat reusable fragment seperti emailSchema dan passwordSchema, lalu menggabungkannya ke dalam berbagai schema form.
export const emailSchema = z.string().email().transform((e) => e.toLowerCase());
export const passwordSchema = z.string().min(8).max(64);
export const loginSchema = z.object({
email: emailSchema,
password: passwordSchema,
});
export const registrationSchema = z.object({
nama: z.string().min(3),
email: emailSchema,
password: passwordSchema,
konfirmasiPassword: z.string(),
}).superRefine(/* ... */);Untuk form multi-step atau halaman dengan beberapa form, kita bisa mendefinisikan schema terpisah untuk setiap langkah atau bagian, lalu menggabungkannya dengan .merge() untuk validasi akhir. Pendekatan ini memudahkan debugging karena setiap bagian memiliki schema yang jelas dan terisolasi.
Terakhir, jangan lupa untuk menulis unit test untuk schema. Zod menyediakan method .parse() yang melempar error jika data tidak valid, dan .safeParse() yang mengembalikan objek { success, data, error } tanpa melempar exception. Kedua method ini sangat berguna untuk memastikan schema berperilaku sesuai yang diharapkan dalam berbagai skenario.
Schema-based validation dengan Zod dan React-Hook-Form adalah pendekatan yang membawa disiplin dan konsistensi ke dalam pengelolaan form di aplikasi React. Kombinasi TypeScript type inference, validasi lintas-field, transformasi data, dan performa uncontrolled components menciptakan fondasi yang kokoh untuk form production-grade.
Tertarik untuk menguasai React, TypeScript, dan best practices pengembangan web modern secara menyeluruh? Bergabunglah dengan Bootcamp Web Development Rumah Coding, tempat kita belajar membangun aplikasi production-ready dengan stack teknologi terkini.
Kursus Terkait
Fullstack Web Development With Next.js
A practical, beginner-friendly, and project-based introduction to full-stack web development. Students will learn to build, secure, and deploy modern web applications from scratch using Next.js (App Router), React, Tailwind CSS, and a relational database. By the end of the course, students will have a fully functional, production-ready application to showcase in their portfolio.
TechConnect - Modern IT Job Portal
- Public Job Board: A responsive homepage displaying available job listings with dynamic routing for individual job detail pages.
- Search & Filter: Basic functionality allowing users to find jobs based on keywords or categories.
- User Authentication: Secure Sign Up, Log In, and Log Out workflows.
MERN Stack Development
Launch your journey into full-stack web development with this comprehensive, project-driven course. Designed for beginners, this course demystifies the MERN stack (MongoDB, Express.js, React.js, Node.js) by guiding you step-by-step in building a real-world application from scratch. By the end of this course, you will have the practical skills and a complete portfolio project to confidently step into the modern web development industry.
EduStream - Mini Learning Management System (LMS)
- Secure Authentication & Authorization: Robust user registration and login using JWT, with strict role-based access control (Admin/Instructor vs. Student).
- Course Management (Admin Dashboard): Full CRUD (Create, Read, Update, Delete) capabilities for administrators to manage course details, including titles, descriptions, pricing, and thumbnail image uploads.
- Public Course Catalog: An interactive and responsive storefront where users can browse available courses.