karawaci.kode

← Semua snippet

TypeScript Menengah Utility

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.infer kasih TS type dari schema.
  • defaultValues penting — RHF butuh undefined vs "" 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.preprocess untuk handle empty string.

# tags

shadcnreact-hook-formzodformui

Ditulis oleh Asti Larasati · 12 Juni 2026