Use Cases (Diperbarui: 2/6/2026)

Validasi Form dengan Claude Code, React Hook Form, dan Zod

Bangun validasi form dengan Claude Code, React Hook Form, Zod, server check, error API, i18n, aksesibilitas, dan test.

Validasi Form dengan Claude Code, React Hook Form, dan Zod

Mulai dari kontrak data, bukan tampilan

Validasi form bukan sekadar menampilkan pesan saat field kosong. Di produk nyata, form sering menjadi pintu masuk trial SaaS, kontak sales, reservasi, checkout, onboarding, dan admin panel. Form bisa terlihat rapi tetapi tetap bermasalah jika mengizinkan submit ganda, mengirim angka sebagai string, menghilangkan error dari server, atau tidak ramah untuk pengguna keyboard dan screen reader.

Claude Code cocok untuk tugas ini karena validasi punya struktur jelas. React Hook Form mengatur state dan submit. Zod menulis schema, yaitu kontrak data. Resolver menghubungkan React Hook Form dan Zod. Server-side validation berarti API memeriksa JSON lagi karena browser bisa dilewati. Normalisasi error berarti mengubah error Zod, aturan bisnis, dan JSON invalid menjadi satu format. i18n adalah internasionalisasi, sementara aksesibilitas berarti error bisa dipahami oleh semua pengguna.

Artikel ini memakai form kontak B2B untuk membahas React Hook Form, Zod, validasi server, normalisasi error API, aksesibilitas, i18n, dan test. Baca juga React Hook Form dengan Claude Code dan validasi Zod dengan Claude Code. Untuk detail terbaru, cek dokumentasi resmi: Claude Code overview, React Hook Form useForm, React Hook Form Resolvers, Zod, Next.js Route Handlers, dan Testing Library.

flowchart TD
  A["User input"] --> B["React Hook Form"]
  B --> C["zodResolver"]
  C --> D{"Client valid?"}
  D -->|No| E["Accessible field errors"]
  D -->|Yes| F["POST /api/contact"]
  F --> G["Server Zod validation"]
  G --> H["Normalize API errors"]
  H --> I["setError or root message"]
  G --> J["Persist or notify"]

Use case menentukan aturan

Jangan hanya meminta “tambahkan validasi”. Jelaskan jenis form dan risiko bisnisnya.

Use caseValidasi pentingJebakan umum
Trial SaaSEmail kantor, jumlah seat, plan, persetujuan syaratTidak memutuskan apakah email pribadi boleh
Form kontakNama, email, kategori, panjang pesan, URL mencurigakanHanya validasi di client dan percaya API body
Edit user adminRole, izin, ID tetap, field yang boleh diubahField disembunyikan di UI tetapi diterima di API
Reservasi atau pre-paymentTanggal, jumlah, telepon, alamat, stokDouble click membuat reservasi ganda

Prompt yang lebih aman:

Implement a contact form.
Only change the files in this feature.
Use React Hook Form, Zod, and @hookform/resolvers/zod.
Validate on the client and again in the API using the same schema.
Normalize API failures as { ok: false, errors: [{ path, message }] }.
Include duplicate-submit prevention, aria-invalid, aria-describedby, role="alert",
i18n message keys, and Vitest/Testing Library tests.
Use copy-pasteable TypeScript and React, not pseudocode.

Prompt ini meminta Claude Code menangani kondisi gagal, bukan hanya tampilan sukses. Saat review, cek tipe data, batas server, tampilan error, pencegahan submit ganda, aksesibilitas, dan test.

Schema Zod siap pakai

Schema berikut memakai message key. API mengirim key, lalu UI menerjemahkannya sesuai locale.

// src/features/contact/contactSchema.ts
import { z } from "zod";

export const contactSchema = z
  .object({
    name: z.string().trim().min(1, "validation.name.required").max(60, "validation.name.tooLong"),
    email: z.string().trim().min(1, "validation.email.required").email("validation.email.invalid"),
    plan: z.enum(["starter", "team", "enterprise"], { message: "validation.plan.invalid" }),
    seats: z
      .number({ message: "validation.seats.number" })
      .int("validation.seats.integer")
      .min(1, "validation.seats.min")
      .max(200, "validation.seats.max"),
    message: z.string().trim().min(20, "validation.message.tooShort").max(1000, "validation.message.tooLong"),
    locale: z.enum(["ja", "en"], { message: "validation.locale.invalid" }),
    agreeToTerms: z.boolean().refine((value) => value === true, "validation.terms.required"),
  })
  .strict();

export type ContactFormData = z.infer<typeof contactSchema>;

export const defaultContactValues: ContactFormData = {
  name: "",
  email: "",
  plan: "starter",
  seats: 1,
  message: "",
  locale: "en",
  agreeToTerms: false,
};

seats sengaja bertipe number. Input HTML number tetap bisa menjadi string kalau wiring salah. Gunakan valueAsNumber di React Hook Form, atau schema API khusus dengan z.coerce.number() untuk data mentah.

Validasi server dan normalisasi error API

Validasi client membantu pengguna, tetapi keamanan ada di server. Body API harus dianggap unknown sampai Zod menerimanya.

// src/app/api/contact/route.ts
import { z } from "zod";
import { contactSchema, type ContactFormData } from "@/features/contact/contactSchema";

type FieldPath = keyof ContactFormData | "root";

export type ApiFieldError = {
  path: FieldPath;
  message: string;
};

export type ContactApiResponse =
  | { ok: true; id: string }
  | { ok: false; errors: ApiFieldError[] };

function normalizeZodError(error: z.ZodError): ApiFieldError[] {
  return error.issues.map((issue) => {
    const firstPath = issue.path[0];
    return {
      path: typeof firstPath === "string" ? (firstPath as FieldPath) : "root",
      message: issue.message,
    };
  });
}

function jsonResponse(body: ContactApiResponse, status: number): Response {
  return Response.json(body, { status });
}

async function isBlockedDomain(email: string): Promise<boolean> {
  return email.toLowerCase().endsWith("@example.invalid");
}

export async function POST(request: Request): Promise<Response> {
  let body: unknown;

  try {
    body = await request.json();
  } catch {
    return jsonResponse({ ok: false, errors: [{ path: "root", message: "validation.json.invalid" }] }, 400);
  }

  const parsed = contactSchema.safeParse(body);
  if (!parsed.success) {
    return jsonResponse({ ok: false, errors: normalizeZodError(parsed.error) }, 422);
  }

  if (await isBlockedDomain(parsed.data.email)) {
    return jsonResponse({ ok: false, errors: [{ path: "email", message: "validation.email.blocked" }] }, 409);
  }

  // Replace this with database insert, CRM sync, or email notification.
  const id = crypto.randomUUID();
  return jsonResponse({ ok: true, id }, 201);
}

Dengan format ini, UI hanya membaca path dan message. Sumber error tidak perlu dibedakan.

Komponen React Hook Form yang aksesibel

Komponen ini mencegah submit ganda, menghubungkan error dengan ARIA, dan memetakan error API kembali ke field.

// src/features/contact/ContactForm.tsx
"use client";

import { useState } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { contactSchema, defaultContactValues, type ContactFormData } from "./contactSchema";
import type { ApiFieldError, ContactApiResponse } from "@/app/api/contact/route";

const messages = {
  en: {
    "validation.name.required": "Enter your name.",
    "validation.name.tooLong": "Name must be 60 characters or fewer.",
    "validation.email.required": "Enter your email address.",
    "validation.email.invalid": "Enter a valid email address.",
    "validation.email.blocked": "This email domain is not allowed.",
    "validation.plan.invalid": "Choose a plan.",
    "validation.seats.number": "Seats must be a number.",
    "validation.seats.integer": "Seats must be an integer.",
    "validation.seats.min": "Seats must be at least 1.",
    "validation.seats.max": "Seats must be 200 or fewer.",
    "validation.message.tooShort": "Message must be at least 20 characters.",
    "validation.message.tooLong": "Message must be 1000 characters or fewer.",
    "validation.locale.invalid": "Locale is invalid.",
    "validation.terms.required": "You must agree to the terms.",
    "validation.json.invalid": "The submitted body could not be read.",
    "form.submitError": "Submit failed. Please try again later.",
  },
} as const;

type Locale = keyof typeof messages;
const formFields = ["name", "email", "plan", "seats", "message", "locale", "agreeToTerms"] as const;
type FormField = (typeof formFields)[number];

function t(locale: Locale, key: string): string {
  const table = messages[locale] as Record<string, string>;
  return table[key] ?? key;
}

function isFormField(path: ApiFieldError["path"]): path is FormField {
  return formFields.includes(path as FormField);
}

export function ContactForm({ locale = "en" }: { locale?: Locale }) {
  const [serverMessage, setServerMessage] = useState<string | null>(null);
  const { register, handleSubmit, setError, reset, formState: { errors, isSubmitting } } =
    useForm<ContactFormData>({ resolver: zodResolver(contactSchema), defaultValues: { ...defaultContactValues, locale }, mode: "onBlur" });

  async function onValidSubmit(values: ContactFormData) {
    setServerMessage(null);
    const response = await fetch("/api/contact", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(values) });
    const result = (await response.json()) as ContactApiResponse;
    if (!response.ok || !result.ok) {
      const apiErrors = result.ok ? [] : result.errors;
      for (const error of apiErrors) {
        if (isFormField(error.path)) setError(error.path, { type: "server", message: t(locale, error.message) });
        else setServerMessage(t(locale, error.message));
      }
      if (apiErrors.length === 0) setServerMessage(t(locale, "form.submitError"));
      return;
    }
    reset({ ...defaultContactValues, locale });
  }

  return (
    <form onSubmit={handleSubmit(onValidSubmit)} noValidate>
      {serverMessage ? <p role="alert" aria-live="assertive">{serverMessage}</p> : null}
      <label htmlFor="contact-name">Name</label>
      <input id="contact-name" aria-invalid={Boolean(errors.name)} aria-describedby={errors.name ? "contact-name-error" : undefined} {...register("name")} />
      {errors.name?.message ? <p id="contact-name-error" role="alert">{t(locale, errors.name.message)}</p> : null}
      <label htmlFor="contact-email">Email address</label>
      <input id="contact-email" type="email" aria-invalid={Boolean(errors.email)} aria-describedby={errors.email ? "contact-email-error" : undefined} {...register("email")} />
      {errors.email?.message ? <p id="contact-email-error" role="alert">{t(locale, errors.email.message)}</p> : null}
      <label htmlFor="contact-plan">Plan</label>
      <select id="contact-plan" {...register("plan")}><option value="starter">Starter</option><option value="team">Team</option><option value="enterprise">Enterprise</option></select>
      <label htmlFor="contact-seats">Seats</label>
      <input id="contact-seats" type="number" min={1} max={200} {...register("seats", { valueAsNumber: true })} />
      <label htmlFor="contact-message">Message</label>
      <textarea id="contact-message" rows={6} {...register("message")} />
      <label><input type="checkbox" {...register("agreeToTerms")} />I agree to the terms</label>
      {errors.agreeToTerms?.message ? <p role="alert">{t(locale, errors.agreeToTerms.message)}</p> : null}
      <button type="submit" disabled={isSubmitting} aria-busy={isSubmitting}>{isSubmitting ? "Submitting..." : "Submit"}</button>
    </form>
  );
}

Untuk produk Indonesia, label dan tombol juga sebaiknya masuk dictionary. Contoh dibuat pendek agar struktur validasi jelas.

Test, jebakan, dan hasil praktik

// src/features/contact/ContactForm.test.tsx
import "@testing-library/jest-dom/vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ContactForm } from "./ContactForm";
import { contactSchema } from "./contactSchema";

const validInput = {
  name: "Masa",
  email: "masa@example.com",
  plan: "team",
  seats: 3,
  message: "I want to improve validation in a Claude Code workflow.",
  locale: "en",
  agreeToTerms: true,
} as const;

it("accepts valid schema input", () => {
  expect(contactSchema.safeParse(validInput).success).toBe(true);
});

it("rejects string seats", () => {
  expect(contactSchema.safeParse({ ...validInput, seats: "3" }).success).toBe(false);
});

it("does not submit invalid form", async () => {
  const fetchMock = vi.spyOn(globalThis, "fetch");
  render(<ContactForm locale="en" />);
  await userEvent.click(screen.getByRole("button", { name: "Submit" }));
  expect(await screen.findByText("Enter your name.")).toBeInTheDocument();
  expect(fetchMock).not.toHaveBeenCalled();
});

Kesalahan yang sering terjadi: validasi hanya di client, tidak mencegah double submit, angka dan checkbox berubah tipe, error server tidak ditampilkan, aksesibilitas dianggap hanya warna merah, dan Claude Code mengubah file di luar scope.

Form adalah jalur konversi. Jika form trial gagal, traffic SEO dan iklan terbuang. Jika form kontak tidak menjelaskan error, lead hilang. ClaudeCodeLab lewat training dan konsultasi Claude Code dapat membantu menyusun prompt, schema, test, dan checklist review pada repo nyata.

Dalam uji coba Masa, versi pertama terlihat bagus, tetapi seats masuk ke API sebagai string dan error domain terblokir tidak muncul di field email. Setelah menambah valueAsNumber, normalisasi error API, dan test regresi Testing Library, kedua masalah bisa ditemukan ulang dan diperbaiki. Message key juga memudahkan UI Jepang dan Inggris memakai response server yang sama.

Ringkasan

Saat memakai Claude Code untuk validasi form, perlakukan form sebagai batas input eksternal: React Hook Form, Zod, validasi server, error API, aksesibilitas, i18n, dan test. Dengan batas yang jelas, hasil Claude Code lebih stabil dan lebih siap dipakai di produksi.

#Claude Code #validation #forms #Zod #React
Gratis

PDF gratis: cheatsheet Claude Code

Masukkan email dan unduh satu halaman berisi command, kebiasaan review, dan workflow aman.

Kami menjaga datamu dan tidak mengirim spam.

Masa

Tentang penulis

Masa

Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.