Form Type-Safe dengan React Hook Form dan Zod

Foto oleh Major Nelson on flickr
zodResolver mengubah schema Zod agar bisa dipakai React Hook Form sebagai resolver validasi. Fungsi ini diimpor dari paket hookform resolvers dan dimasukkan ke opsi resolver pada useForm, sehingga setiap field yang didaftarkan divalidasi sesuai aturan schema dan pesan hasilnya otomatis mengisi errors pada formState.
Memakai satu schema berarti aturan validasi dan tipe TypeScript hanya ada di satu tempat, sehingga perubahan aturan otomatis berlaku di mana pun schema tersebut diimpor. Ini juga menutup celah umum di mana pengecekan di client dan di server perlahan berbeda setelah beberapa kali diedit, karena kedua sisi menjalankan kode yang benar-benar sama.
Bisa. Utilitas inferensi milik Zod membaca bentuk dan aturan sebuah schema lalu menghasilkan tipe TypeScript yang sesuai secara otomatis. Artinya props form, generic pada useForm, dan tipe payload route API bisa merujuk ke tipe hasil inferensi yang sama, bukan tiga interface yang dikelola secara terpisah.
Gunakan metode refine milik Zod setelah schema objek didefinisikan. Metode ini berjalan setelah setiap field lolos aturannya masing-masing, memungkinkan perbandingan dua field atau lebih, dan menerima opsi path yang memberi tahu React Hook Form field mana yang harus menampilkan pesan error tersebut.
Tidak. Validasi di sisi client hanya meningkatkan pengalaman pengguna; ia tidak melindungi route API dari request yang melewati form sama sekali. Schema Zod yang sama harus diimpor ke route server dan dijalankan lewat safeParse agar data yang tidak valid tetap ditolak dari mana pun request itu berasal.

Foto oleh Major Nelson on flickr
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.
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.
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 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:
// 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.
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. |
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.
Ada beberapa kebiasaan yang membedakan form yang terlihat type-safe dari form yang benar-benar type-safe. Periksa poin-poin ini sebelum melakukan merge:
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.
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: "[email protected]",
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.