Ringkasan Utama
React Hook Form yang dipasangkan dengan schema Zod memungkinkan satu file mendefinisikan aturan validasi, menurunkan tipe TypeScript secara otomatis, dan memvalidasi ulang aturan yang sama persis di route API lewat safeParse, sehingga validasi client, validasi server, dan tipe data tidak pernah saling menyimpang. Aturan antar-field memakai metode refine milik Zod, dan schema itu sendiri bisa diuji unit tanpa merender form sama sekali.
Setiap form pada akhirnya menghadapi tiga masalah yang sama: validasi di client mulai berbeda dari validasi di server, tipe TypeScript ikut menyimpang dari keduanya, dan pesan error tersebar di berbagai komponen alih-alih berada di dekat aturan yang mendefinisikannya. React Hook Form yang dipasangkan dengan Zod menyelesaikan ketiga masalah ini sekaligus, dan caranya lebih sederhana dari yang biasanya dibayangkan.
Tulisan ini membahas setup ala production: satu schema Zod yang mendefinisikan aturan validasi sekaligus menjadi tipe TypeScript, zodResolver yang dipasang ke useForm, route API yang memvalidasi ulang schema yang sama di sisi server, dan pola callout yang perlu diketahui sebelum form dirilis ke pengguna nyata.
Definisikan schema sekali, turunkan tipenya dari sana
Ide utamanya adalah berhenti menulis interface TypeScript dan fungsi validasi secara terpisah. Schema Zod mendeskripsikan bentuk data sekaligus aturan yang harus dipenuhi, dan TypeScript bisa membaca tipe tersebut langsung dari schema menggunakan utilitas inferensi bawaannya. Ubah satu aturan di schema, dan tipe tersebut ikut ter-update secara otomatis, sehingga komponen form, handler submit, dan route API tetap sinkron tanpa perlu diedit ulang.
// schemas/contact-form.ts
import { z } from "zod"
export const contactFormSchema = z.object({
name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Enter a valid email address"),
company: z.string().optional(),
message: z.string().min(10, "Message must be at least 10 characters"),
})
// Infer the TypeScript type from the schema — one source of truth
export type ContactFormValues = z.infer<typeof contactFormSchema>Jaga agar file schema tetap bebas dari framework tertentu. Schema di schemas slash contact form dot ts sebaiknya tidak mengimpor apa pun dari React Hook Form atau Next.js, sehingga file yang sama persis bisa dipakai baik di form pada browser maupun di route API tanpa kode adapter tambahan.
Pasangkan resolver ke useForm
Paket hookform resolvers menyediakan fungsi zodResolver yang mengubah schema Zod menjadi bentuk resolver yang dibutuhkan React Hook Form. Dengan memasukkan schema melalui zodResolver ke useForm, setiap field yang didaftarkan lewat fungsi register akan divalidasi dengan aturan yang sama, dan objek errors pada formState terisi dengan pesan yang persis didefinisikan di schema, tanpa logika validasi terpisah di komponen.
// components/ContactForm.tsx
"use client"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { contactFormSchema, type ContactFormValues } from "@/schemas/contact-form"
export function ContactForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<ContactFormValues>({
resolver: zodResolver(contactFormSchema),
mode: "onBlur",
})
const onSubmit = async (values: ContactFormValues) => {
const res = await fetch("/api/contact", {
method: "POST",
body: JSON.stringify(values),
})
if (!res.ok) throw new Error("Submission failed")
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("name")} />
{errors.name && <span>{errors.name.message}</span>}
<input {...register("email")} />
{errors.email && <span>{errors.email.message}</span>}
<textarea {...register("message")} />
{errors.message && <span>{errors.message.message}</span>}
<button type="submit" disabled={isSubmitting}>
Send
</button>
</form>
)
}Perhatikan bahwa komponen tidak pernah memanggil parse atau safeParse secara manual. React Hook Form menjalankan resolver secara internal pada setiap pemicu validasi, baik itu onBlur, onChange, maupun saat submit, tergantung mode yang dikonfigurasi. Komponen hanya membaca objek errors yang dihasilkan dan flag isSubmitting, sehingga JSX tetap fokus pada tampilan alih-alih urusan validasi.
Validasi ulang schema yang sama di sisi server
Validasi di sisi client adalah fitur pengalaman pengguna, bukan batas keamanan. Sebuah request selalu bisa mencapai route API tanpa melewati form, entah dari ekstensi browser, request yang diputar ulang, atau client yang sama sekali berbeda, sehingga aturan yang sama harus dijalankan lagi di server. Mengimpor schema yang identik ke dalam route handler dan memanggil safeParse memberikan tiga keuntungan sekaligus:
- Server menolak payload yang tidak valid dengan pesan per-field yang persis sama dengan yang sudah ditampilkan di client, sehingga kode penanganan error tidak digandakan dalam bentuk yang berbeda.
- Karena schema berada di modul yang dipakai bersama, perubahan aturan dijamin berlaku di kedua sisi begitu kode di-deploy, menutup celah klasik di mana validasi client dan server perlahan menyimpang seiring waktu.
- result dot data dari pemanggilan safeParse yang berhasil sudah bertipe sesuai tipe hasil inferensi schema, sehingga sisa kode handler bekerja dengan data yang sudah bertipe penuh dan sudah tervalidasi, bukan body request yang belum diketahui tipenya.
// app/api/contact/route.ts
import { NextResponse } from "next/server"
import { contactFormSchema } from "@/schemas/contact-form"
export async function POST(request: Request) {
const body = await request.json()
const result = contactFormSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ errors: result.error.flatten().fieldErrors },
{ status: 400 }
)
}
// result.data is fully typed as ContactFormValues here
await sendEmail(result.data)
return NextResponse.json({ ok: true })
}Jangan melewatkan validasi di sisi server hanya karena client sudah memeriksa field-nya. Bundle di client bisa diperiksa dan diubah langsung dari browser, dan setiap endpoint yang bisa diakses lewat jaringan harus berasumsi bahwa body request belum tervalidasi.
Memilih mode pemicu validasi
useForm menerima opsi mode yang menentukan kapan resolver benar-benar dijalankan, dan memilih mode yang salah adalah sumber umum form yang terasa lambat atau justru terlalu cerewet. Tabel berikut mencakup tiga mode yang paling sering dipakai pada form production.
| Mode | Kapan divalidasi | Trade-off |
|---|---|---|
| onSubmit | Hanya saat form disubmit | Terasa tenang saat mengetik, tapi pengguna baru menemukan kesalahan setelah menekan submit. |
| onBlur | Saat field kehilangan fokus, lalu live setelah error pertama muncul | Default terbaik untuk kebanyakan form — feedback muncul tepat setelah pengguna selesai mengisi satu field. |
| onChange | Setiap kali ada ketikan | Feedback langsung, tapi bisa terasa mengganggu pada field yang panjang seperti password atau teks bebas. |
Aturan antar-field dengan refine
Sebagian aturan validasi tidak bisa diungkapkan pada satu field saja, contoh klasiknya adalah field konfirmasi password yang harus cocok dengan password aslinya. Metode refine milik Zod berjalan setelah aturan per-field lolos, dan memungkinkan perbandingan beberapa field sekaligus, lalu melekatkan error yang dihasilkan ke path field tertentu sehingga React Hook Form menampilkannya di tempat yang tepat.
// schemas/signup-form.ts
import { z } from "zod"
export const signupFormSchema = z
.object({
email: z.string().email(),
password: z.string().min(8, "Password must be at least 8 characters"),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Passwords do not match",
path: ["confirmPassword"],
})
export type SignupFormValues = z.infer<typeof signupFormSchema>Opsi path pada pemanggilan refine adalah yang membuat ini berguna dalam sebuah form. Tanpa opsi ini, error akan muncul sebagai error level-form tanpa field yang jelas untuk dilekatkan. Dengan path diarahkan ke field konfirmasi password, objek errors milik React Hook Form menampilkannya persis seperti error field lainnya, sehingga JSX tidak perlu penanganan khusus untuk merender error tersebut.
Karena refine berjalan sebagai bagian dari schema yang sama, aturan antar-field ini diberlakukan secara identik di client maupun server, tanpa kode tambahan di kedua sisi.
Checklist singkat sebelum merilis form
Ada beberapa kebiasaan yang membedakan form yang terlihat type-safe dari form yang benar-benar type-safe. Periksa poin-poin ini sebelum melakukan merge:
- Pastikan komponen tidak pernah mendeklarasikan ulang tipe yang seharusnya bisa diturunkan dari schema — tipe yang digandakan adalah hal pertama yang menyimpang.
- Pastikan route API mengimpor file schema yang sama dengan komponen client, bukan salinan atau duplikat yang ditulis ulang secara manual.
- Pastikan setiap field array dan field opsional memiliki nilai default yang jelas di defaultValues milik useForm, karena field opsional pada Zod bisa membuat input menjadi uncontrolled jika tidak diatur.
- Pastikan pesan error ditulis untuk pengguna akhir, bukan untuk developer — pesan seharusnya menjelaskan apa yang harus diperbaiki, bukan sekadar menyatakan bahwa suatu aturan gagal.
Pecah form yang besar menjadi beberapa schema Zod yang lebih kecil, lalu gabungkan dengan merge atau extend alih-alih menulis satu schema objek yang sangat besar. Cara ini membuat setiap bagian tetap mudah diuji sendiri dan mempermudah pembuatan wizard multi-langkah di kemudian hari.
Menguji schema, bukan menguji form
Karena aturan validasi berada di file schema biasa tanpa dependensi ke React atau DOM, aturan tersebut bisa diuji langsung sebagai unit test tanpa perlu merender satu komponen pun. File test yang mengimpor schema dan memanggil safeParse dengan beberapa payload valid dan tidak valid akan menangkap regresi aturan jauh lebih cepat dibanding test berbasis browser yang harus merender form, mengisi field, lalu memeriksa teks error yang ditampilkan.
// schemas/contact-form.test.ts
import { describe, it, expect } from "vitest"
import { contactFormSchema } from "./contact-form"
describe("contactFormSchema", () => {
it("rejects an invalid email", () => {
const result = contactFormSchema.safeParse({
name: "Ada",
email: "not-an-email",
message: "Hello there, this is a test.",
})
expect(result.success).toBe(false)
})
it("accepts a valid payload", () => {
const result = contactFormSchema.safeParse({
name: "Ada",
email: "ada@example.com",
message: "Hello there, this is a test.",
})
expect(result.success).toBe(true)
})
})Ini juga berarti test schema sekaligus berfungsi sebagai dokumentasi hidup untuk setiap aturan dalam form. Reviewer yang ingin tahu persis apa yang membuat field email tidak valid tidak perlu menelusuri kode komponen — jawabannya sudah ada di file test schema, dinyatakan sebagai contoh payload konkret yang lolos atau gagal.
Pola pada tulisan ini bisa diterapkan mulai dari form kontak dua field hingga alur checkout multi-langkah tanpa mengubah bentuknya: satu schema, satu tipe hasil inferensi, satu resolver, dan satu pemanggilan safeParse di server yang mencerminkan client. Begitu schema bersama ini ada, menambah satu field berarti mengedit tepat satu file, bukan tiga.

