Form login 2 field cukup pakai useState + validasi sederhana. Tapi form registrasi 10 field dengan validasi kompleks (email format, password 8+ karakter dengan simbol, pengulangan password match, captcha, conditional fields)? Pakai useState manual jadi mimpi buruk. Pas form mulai gemuk, validasi + state management jadi 80% kerjaan, 20% UI. Solusi: pisahin validasi rules dari UI, pakai library seperti React Hook Form + Zod — validasi schema-based, error handling otomatis, performa bagus.
💡 Stack modern 2026.
react-hook-form(state + validation) +zod(schema) +@hookform/resolvers/zod(binding). Combine ini dipake di hampir semua project React production tahun ini. Project hypersheets-frontend juga pakai pattern ini. Belajarnya 2 jam, hemat ribuan jam debugging form bug.
Validasi Manual
function RegistrationForm() {
const [form, setForm] = useState({ name: "", email: "", password: "" })
const [errors, setErrors] = useState({})
const validate = () => {
const e = {}
if (!form.name.trim()) e.name = "Nama wajib diisi"
else if (form.name.length < 3) e.name = "Minimal 3 karakter"
if (!form.email.includes("@")) e.email = "Email tidak valid"
if (form.password.length < 8) e.password = "Password minimal 8 karakter"
return e
}
const handleSubmit = (e) => {
e.preventDefault()
const validationErrors = validate()
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors)
return
}
setErrors({})
// Submit ke API...
}
return (
<form onSubmit={handleSubmit}>
<input
value={form.name}
onChange={e => setForm({...form, name: e.target.value})}
/>
{errors.name && <span className="error">{errors.name}</span>}
{/* repeat untuk email, password... */}
<button type="submit">Daftar</button>
</form>
)
}
Cape ya? 10 field × ini = 200 baris boilerplate.
React Hook Form — Recommended
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "zod"
const schema = z.object({
name: z.string().min(3, "Minimal 3 karakter"),
email: z.string().email("Email tidak valid"),
password: z.string().min(8, "Password minimal 8 karakter"),
})
function RegistrationForm() {
const { register, handleSubmit, formState: { errors, isSubmitting } } =
useForm({ resolver: zodResolver(schema) })
const onSubmit = async (data) => {
await fetch("/api/register", { method: "POST", body: JSON.stringify(data) })
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("name")} />
{errors.name && <span>{errors.name.message}</span>}
<input {...register("email")} type="email" />
{errors.email && <span>{errors.email.message}</span>}
<input {...register("password")} type="password" />
{errors.password && <span>{errors.password.message}</span>}
<button disabled={isSubmitting}>{isSubmitting ? "Loading..." : "Daftar"}</button>
</form>
)
}
Lebih pendek, validasi declarative, error handling otomatis, performa bagus (cuma re-render field yang berubah).
Tips Validasi UX
- Validasi saat submit dulu (bukan tiap keystroke — annoying)
- Pas user udah pernah submit, switch ke real-time validation pas ngetik (catch + immediately feedback)
- Atau validasi
onBlur(saat user pindah field) — sweet spot - Tampilkan error dekat field — bukan di atas/bawah form jauh
- Disable submit button saat
isSubmitting(cegah double submit) - Loading state di button ("Loading...") biar user gak nge-klik berulang
- Pesan error spesifik — "Email tidak valid" > "Error pada field email"
Conditional Fields
const watchedJob = watch("job")
return (
<>
<select {...register("job")}>
<option value="employed">Karyawan</option>
<option value="freelance">Freelancer</option>
</select>
{watchedJob === "employed" && (
<input {...register("companyName", { required: true })} placeholder="Nama Perusahaan" />
)}
</>
)
🎭 Analogi sehari-hari
Form manual itu kayak gerai bakso pinggir jalan — segala-galanya pemilik pakai sendiri: kasir, koki, pelayan, validator pesanan ("eh kembaliannya kurang"). Buat 5 customer per hari OK. Buat 50 customer/hari? Stress, kepala panas, salah hitung. Form library = POS system Tokopedia/GoFood: ada role spesialis (validator schema = Zod, state manager = react-hook-form, submit handler = onSubmit). Tiap komponen kerjaan tertentu, terkoordinasi otomatis. Lebih cepat, lebih jarang typo, scalable. Trade-off: belajar tools dulu, tapi sekali paham — produktivitas naik drastis.
⚠️ Jebakan yang sering ditemui
- Validasi tiap keystroke — annoying UX. Validasi saat submit + onBlur lebih sweet spot.
- Error message generic — "Required" gak helpful. Pesan spesifik: "Nama minimal 3 karakter".
- Submit button gak disabled saat submitting — user double-klik = double submit. Selalu
disabled={isSubmitting}. - Lupa
e.preventDefault()— page reload, data hilang. Wajib di onSubmit. - Validasi cuma frontend — user bisa bypass via DevTools. Server-side validation TETAP WAJIB.
- State form di parent — re-render parent tiap keystroke = lambat. Pakai library yang isolate state.
- File upload + validation ribet di useState — pakai library yang punya
Controlleratau handle file natively. - Reset form gak clean — manual reset state semua field = bug. Library kasih
reset()function.
🎯 Pilih form approach — pakai mana?
- 1-2 field, validasi simpel → useState manual ✓
- 3-5 field, validasi medium → useState + helper validate() function
- 6+ field, validasi kompleks, multi-step → React Hook Form + Zod (modern default)
- Form dengan banyak conditional fields → React Hook Form + watch
- Form dengan dynamic fields (array) → useFieldArray dari RHF
- Form yang submit langsung tanpa validation → controlled biasa OK
Aturan: kalau form mulai > 100 baris kode, waktunya pindah ke library. Hemat waktu jangka panjang.
TL;DR: Form sederhana? useState + validate() function manual cukup. Form kompleks (6+ field, validasi rumit, conditional, multi-step)? React Hook Form + Zod — schema-based validation, error handling otomatis, performance bagus. Validasi saat submit dulu, switch ke onBlur/real-time setelah submit pertama. Disable submit button saat isSubmitting (cegah double submit). Frontend validation = UX, server validation TETAP WAJIB (security).