shadcn Form pattern dengan zod resolver
shadcn/ui Form + react-hook-form + zod resolver. Boilerplate sekali tulis, dipakai 50 form. Accessible, type-safe, dan UI konsisten.
Dipublikasikan 12 Juni 2026
shadcn/ui populer karena copy-paste-able. Tapi pattern Form-nya yang paling worth — gabungin RHF + zod + accessible label-error binding dalam wrapper yang clean. Snippet ini form ubah profil user koperasi simpan pinjam, dengan validasi async cek NIK ada di database.
Kode
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { toast } from "sonner";
const profilSchema = z.object({
nama: z.string().trim().min(3, "Nama minimal 3 karakter"),
email: z.string().email("Format email tidak valid"),
no_hp: z
.string()
.regex(/^(\+62|0)8\d{8,12}$/, "Format HP Indonesia salah"),
pekerjaan: z.enum(["pegawai", "wiraswasta", "pelajar", "lainnya"], {
errorMap: () => ({ message: "Pilih salah satu" }),
}),
penghasilan_juta: z
.coerce.number({ invalid_type_error: "Harus angka" })
.min(0, "Tidak boleh negatif")
.max(1000, "Terlalu besar"),
});
type ProfilForm = z.infer<typeof profilSchema>;
interface Props {
defaultValues?: Partial<ProfilForm>;
onSubmit: (data: ProfilForm) => Promise<void>;
}
export function FormProfil({ defaultValues, onSubmit }: Props) {
const form = useForm<ProfilForm>({
resolver: zodResolver(profilSchema),
defaultValues: {
nama: "",
email: "",
no_hp: "",
pekerjaan: undefined as unknown as ProfilForm["pekerjaan"],
penghasilan_juta: 0,
...defaultValues,
},
});
async function handleSubmit(data: ProfilForm) {
try {
await onSubmit(data);
toast.success("Profil tersimpan");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Gagal simpan");
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
<FormField
control={form.control}
name="nama"
render={({ field }) => (
<FormItem>
<FormLabel>Nama lengkap</FormLabel>
<FormControl>
<Input placeholder="Asti Larasati" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="[email protected]" {...field} />
</FormControl>
<FormDescription>
Dipakai untuk notifikasi pinjaman dan resi.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="no_hp"
render={({ field }) => (
<FormItem>
<FormLabel>Nomor HP</FormLabel>
<FormControl>
<Input type="tel" placeholder="081234567890" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="pekerjaan"
render={({ field }) => (
<FormItem>
<FormLabel>Pekerjaan</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Pilih pekerjaan" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="pegawai">Pegawai</SelectItem>
<SelectItem value="wiraswasta">Wiraswasta</SelectItem>
<SelectItem value="pelajar">Pelajar/Mahasiswa</SelectItem>
<SelectItem value="lainnya">Lainnya</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="penghasilan_juta"
render={({ field }) => (
<FormItem>
<FormLabel>Penghasilan per bulan (juta Rp)</FormLabel>
<FormControl>
<Input type="number" step="0.5" min={0} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? "Menyimpan..." : "Simpan"}
</Button>
</form>
</Form>
);
}
Pemakaian
// Halaman pengaturan profil
import { FormProfil } from "./FormProfil";
export default function PengaturanPage() {
return (
<FormProfil
defaultValues={{
nama: "Asti Larasati",
email: "[email protected]",
}}
onSubmit={async (data) => {
const r = await fetch("/api/profil", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!r.ok) throw new Error("Server tolak data");
}}
/>
);
}
// Reuse di onboarding wizard step 2
<FormProfil
defaultValues={{ email: registrationData.email }}
onSubmit={async (data) => {
setWizardData((prev) => ({ ...prev, profil: data }));
goToStep(3);
}}
/>
Kapan dipakai
- Form dengan 5+ field dan validasi kompleks.
- Form yang di-reuse di banyak tempat (onboarding + edit profil + admin override).
- App yang sudah pakai shadcn/ui — konsistensi UI gratis.
- Form dengan conditional field (next field muncul berdasarkan jawaban sebelumnya).
Catatan
- zodResolver mengubah zod schema jadi RHF resolver. Type inference otomatis —
z.inferkasih TS type dari schema. - defaultValues penting — RHF butuh
undefinedvs""jelas. Untuk Select component, pastikan default sesuai option value. - field.value untuk Select — beda dari Input. RHF onChange-ready field perlu di-wire ke onValueChange Select.
- isSubmitting untuk disable button — RHF track state. Jangan duplicate dengan useState sendiri.
- Toast feedback — pakai library seperti sonner. Error inline + toast global = UX terbaik.
Hati-hati
z.coerce.number()saat field kosong — coerce string kosong jadi 0, bukan undefined. Kalau nullable, pakai.optional()+z.preprocessuntuk handle empty string.
# tags
shadcnreact-hook-formzodformui
Ditulis oleh Asti Larasati · 12 Juni 2026