Kenapa ini penting
Form ada di mana-mana: login, daftar, checkout, tambah tugas, edit profil. Dengan React biasa, setiap input butuh useState, handler onChange, logika validasi, pesan error, status "sedang mengirim", dan reset setelah sukses. Form dengan 10 input bisa menghasilkan puluhan baris kode berulang, dan karena setiap ketikan mengubah state, seluruh form re-render di setiap huruf.
React Hook Form (RHF) menyelesaikan ini dengan pendekatan berbeda: ia membiarkan browser yang menyimpan nilai input (uncontrolled) dan hanya "membaca" nilai saat dibutuhkan. Hasilnya: kode lebih ringkas, re-render jauh lebih sedikit, dan validasi tetap lengkap. Versi yang dipakai: react-hook-form 7.90.0.
Konsep inti
Controlled vs uncontrolled
- Controlled: nilai input disimpan di state React.
value={nama}danonChange={e => setNama(e.target.value)}. React selalu tahu nilainya, tapi setiap ketikan memicu render. - Uncontrolled: nilai disimpan oleh elemen DOM itu sendiri. React membacanya lewat
refsaat dibutuhkan (misalnya saat submit).
Analogi: controlled itu seperti guru yang berdiri di samping murid dan mencatat setiap huruf yang ditulis murid di buku catatannya sendiri. Uncontrolled itu seperti guru yang membiarkan murid menulis, lalu baru membaca lembar jawaban saat dikumpulkan. Yang kedua jauh lebih hemat tenaga, dan itulah cara kerja RHF.
useForm
const {
register, // menghubungkan input ke form
handleSubmit, // membungkus fungsi submit + menjalankan validasi
formState, // status form: errors, isSubmitting, isDirty, isValid, ...
reset, // mengembalikan form ke nilai awal
watch, // memantau nilai input (memicu render)
setValue, // mengisi nilai secara program
setError, // memasang error manual (misalnya dari server)
control, // dipakai untuk komponen controlled (Controller)
} = useForm({
defaultValues: { title: "", priority: "sedang" },
mode: "onTouched", // kapan validasi berjalan
});
Pilihan mode:
| mode | Validasi berjalan saat... |
|---|---|
onSubmit (default) |
Tombol submit ditekan |
onBlur |
Input kehilangan fokus |
onChange |
Setiap ketikan (paling banyak render) |
onTouched |
Pertama kali saat blur, lalu setiap perubahan |
all |
Blur dan change |
register
register("nama") mengembalikan { name, onChange, onBlur, ref } yang kamu sebar ke input:
<input {...register("title", { required: "Judul wajib diisi", minLength: { value: 3, message: "Minimal 3 karakter" } })} />
Aturan validasi bawaan: required, minLength, maxLength, min, max, pattern, validate (fungsi kustom). Ada juga opsi transformasi: valueAsNumber: true (input angka menjadi number, input kosong menjadi NaN) dan valueAsDate: true.
handleSubmit
<form onSubmit={handleSubmit(onValid, onInvalid)}>
handleSubmit mencegah reload halaman (preventDefault), menjalankan validasi, lalu memanggil onValid(data) kalau valid atau onInvalid(errors) kalau tidak. Kalau onValid adalah fungsi async, formState.isSubmitting bernilai true sampai Promise-nya selesai, cocok untuk menonaktifkan tombol.
formState
| Properti | Arti |
|---|---|
errors |
Object error per field, misalnya errors.title.message |
isSubmitting |
Sedang menjalankan onValid |
isSubmitSuccessful |
Submit terakhir berhasil |
isDirty |
Ada nilai yang berbeda dari defaultValues |
isValid |
Semua field valid |
touchedFields, dirtyFields |
Field mana yang sudah disentuh / diubah |
RHF memakai Proxy: ia hanya memantau properti formState yang benar-benar kamu baca. Karena itu, destructure properti yang kamu butuhkan, misalnya const { errors, isSubmitting } = formState.
Komponen controlled dengan Controller
Beberapa komponen UI (date picker, select kustom, checkbox dari library UI) tidak bekerja dengan ref input biasa; mereka butuh value dan onChange. Untuk itu ada Controller, yang akan kita pakai di modul shadcn/ui.
Kapan tetap memakai useState biasa?
React Hook Form bukan kewajiban untuk setiap input. Kolom pencarian tunggal yang hasilnya langsung memfilter daftar, atau toggle sederhana, lebih wajar memakai useState atau store Zustand, karena memang perlu bereaksi di setiap ketikan. RHF bersinar ketika ada form yang dikirim: beberapa field, aturan validasi, status mengirim, dan reset.
Contoh nyata: halaman checkout toko online punya nama, alamat, kota, kode pos, nomor HP, dan catatan. Dengan RHF, mengetik alamat panjang tidak membuat ringkasan belanja di sisi kanan ikut dirender ulang, dan semua pesan error bisa muncul sekaligus saat tombol "Bayar" ditekan.
Contoh kode
// src/features/todos/TaskForm.jsx, form tambah tugas dengan validasi bawaan RHF
import { useForm } from "react-hook-form";
const tunggu = (ms) => new Promise((r) => setTimeout(r, ms));
export default function TaskForm() {
const {
register,
handleSubmit,
reset,
watch,
formState: { errors, isSubmitting, isDirty },
} = useForm({
defaultValues: { title: "", priority: "sedang", estimasi: 1, catatan: "" },
mode: "onTouched",
});
const judul = watch("title"); // memantau satu field untuk penghitung karakter
async function onValid(data) {
await tunggu(800); // pura-pura kirim ke server
console.log("Data dikirim:", data);
reset(); // kosongkan form setelah sukses
}
return (
<form onSubmit={handleSubmit(onValid)} className="mx-auto max-w-md space-y-4 p-6" noValidate>
<div>
<label htmlFor="title" className="block text-sm font-medium">Judul tugas</label>
<input
id="title"
className="w-full rounded border px-3 py-2"
aria-invalid={errors.title ? "true" : "false"}
{...register("title", {
required: "Judul wajib diisi",
minLength: { value: 3, message: "Judul minimal 3 karakter" },
maxLength: { value: 80, message: "Judul maksimal 80 karakter" },
})}
/>
<p className="text-xs text-slate-500">{judul.length}/80</p>
{errors.title && <p className="text-sm text-red-600">{errors.title.message}</p>}
</div>
<div>
<label htmlFor="priority" className="block text-sm font-medium">Prioritas</label>
<select id="priority" className="w-full rounded border px-3 py-2" {...register("priority")}>
<option value="rendah">Rendah</option>
<option value="sedang">Sedang</option>
<option value="tinggi">Tinggi</option>
</select>
</div>
<div>
<label htmlFor="estimasi" className="block text-sm font-medium">Estimasi (jam)</label>
<input
id="estimasi"
type="number"
className="w-full rounded border px-3 py-2"
{...register("estimasi", {
valueAsNumber: true,
min: { value: 1, message: "Minimal 1 jam" },
max: { value: 40, message: "Maksimal 40 jam" },
})}
/>
{errors.estimasi && <p className="text-sm text-red-600">{errors.estimasi.message}</p>}
</div>
<div>
<label htmlFor="catatan" className="block text-sm font-medium">Catatan</label>
<textarea id="catatan" className="w-full rounded border px-3 py-2" {...register("catatan")} />
</div>
<button
type="submit"
disabled={isSubmitting || !isDirty}
className="rounded bg-slate-900 px-4 py-2 text-white disabled:opacity-50"
>
{isSubmitting ? "Menyimpan..." : "Simpan tugas"}
</button>
</form>
);
}
// → Submit dengan judul "ab": muncul "Judul minimal 3 karakter".
// → Submit valid: console mencetak
// Data dikirim: { title: "Belajar RHF", priority: "tinggi", estimasi: 3, catatan: "" }
// perhatikan estimasi berupa number (bukan "3") karena valueAsNumber.
Atribut noValidate mematikan validasi bawaan browser (gelembung "Please fill out this field") supaya pesan dari RHF yang tampil dan konsisten di semua browser.
Kesalahan umum
- Lupa menyebar
{...register("nama")}. Menulisregister="title"atau lupa titik tiga membuat input tidak terhubung;data.titlejadiundefined. - Memakai
value+onChangesendiri bersamaregister. Ini mencampur controlled dan uncontrolled; nilai bisa tidak sinkron. Pilihregistersaja, atauControlleruntuk komponen controlled. - Nilai angka berupa string. Input
type="number"tetap menghasilkan string kecuali pakaivalueAsNumber: true(atau validasi dengan Zod yang mengubah tipenya). - Menaruh
watch()tanpa argumen di komponen besar.watch()memantau semua field sehingga setiap ketikan me-render ulang seluruh form. Pantau field tertentu saja, atau gunakanuseWatchdi komponen kecil. - Lupa
type="submit"atau memanggilhandleSubmitdionClicktombol. PasanghandleSubmitdionSubmitmilik<form>supaya tombol Enter juga berfungsi.
Latihan
- Mudah, Buat form login dengan
emaildanpassword. Keduanya wajib; tampilkan pesan error di bawah input. - Sedang, Tambahkan aturan
patternuntuk email danvalidatekustom untuk password: harus mengandung minimal satu angka. - Menantang, Buat form "Edit tugas" yang menerima prop
task. Isi form dengan data tugas itu (pakaidefaultValuesdanresetsaat prop berubah), dan tombol "Simpan" hanya aktif kalau ada perubahan (isDirty).
Lihat solusi
// 1 & 2. Form login
import { useForm } from "react-hook-form";
export function LoginForm() {
const { register, handleSubmit, formState: { errors } } = useForm({
defaultValues: { email: "", password: "" },
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))} noValidate>
<input
placeholder="Email"
{...register("email", {
required: "Email wajib diisi",
pattern: { value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: "Format email tidak valid" },
})}
/>
{errors.email && <p>{errors.email.message}</p>}
<input
type="password"
placeholder="Password"
{...register("password", {
required: "Password wajib diisi",
validate: (v) => /\d/.test(v) || "Password harus mengandung angka",
})}
/>
{errors.password && <p>{errors.password.message}</p>}
<button type="submit">Masuk</button>
</form>
);
}
// → password "rahasia" → "Password harus mengandung angka"
// 3. Form edit
import { useEffect } from "react";
import { useForm } from "react-hook-form";
export function EditTaskForm({ task, onSave }) {
const { register, handleSubmit, reset, formState: { isDirty, isSubmitting } } = useForm({
defaultValues: { title: task.title, completed: task.completed },
});
// kalau task yang diedit berganti, isi ulang form dengan data baru
useEffect(() => {
reset({ title: task.title, completed: task.completed });
}, [task, reset]);
return (
<form onSubmit={handleSubmit(onSave)}>
<input {...register("title", { required: "Judul wajib diisi" })} />
<label>
<input type="checkbox" {...register("completed")} /> Selesai
</label>
<button type="submit" disabled={!isDirty || isSubmitting}>Simpan</button>
</form>
);
}
Ringkasan
- React Hook Form memakai input uncontrolled sehingga re-render jauh lebih sedikit dibanding
useStateper input. useForm({ defaultValues, mode })menyediakanregister,handleSubmit,formState,reset,watch,setValue,setError,control.{...register("nama", aturan)}menghubungkan input; aturan bawaan: required, minLength, maxLength, min, max, pattern, validate.handleSubmit(onValid)mencegah reload, memvalidasi, lalu memanggil fungsimu;isSubmittingaktif selama Promise berjalan.- Komponen controlled pihak ketiga dihubungkan lewat
Controller.