Use Cases (Mis à jour: 02/06/2026)

Validation de formulaires avec Claude Code, React Hook Form et Zod

Implémentez validation React Hook Form, Zod, serveur, erreurs API, i18n, accessibilité et tests avec Claude Code.

Validation de formulaires avec Claude Code, React Hook Form et Zod

Définir le contrat avant de générer le formulaire

La validation de formulaire n’est pas un détail cosmétique. Dans un produit réel, elle touche les demandes commerciales, essais SaaS, réservations, paiements, profils utilisateur et écrans d’administration. Un formulaire peut paraître correct tout en laissant passer un double envoi, un nombre envoyé comme chaîne, une erreur API jamais affichée ou un message inutilisable pour un lecteur d’écran.

Claude Code est efficace pour ce travail parce que la validation est structurée. React Hook Form gère l’état et le submit. Zod écrit le schema, c’est-à-dire le contrat de données. Le resolver relie React Hook Form et Zod. La validation côté serveur vérifie à nouveau le JSON car le navigateur peut être contourné. La normalisation des erreurs transforme les erreurs Zod, les règles métier et les échecs JSON dans un même format. i18n signifie internationalisation; accessibilité signifie que l’état d’erreur reste compréhensible au clavier et avec les technologies d’assistance.

Ce guide utilise un formulaire de contact B2B pour couvrir React Hook Form, Zod, validation serveur, normalisation des erreurs API, accessibilité, i18n et tests. Pour compléter, consultez React Hook Form avec Claude Code et validation Zod avec Claude Code. Vérifiez les détails dans les sources officielles: Claude Code overview, React Hook Form useForm, React Hook Form Resolvers, Zod, Next.js Route Handlers et 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"]

Cas d’usage à distinguer

Ne demandez pas seulement “ajoute la validation”. Le contexte change les règles.

Cas d’usageValidation importantePiège courant
Essai SaaSEmail professionnel, taille d’équipe, plan, conditionsNe pas décider si les emails personnels sont acceptés
ContactNom, email, catégorie, longueur du message, URLs suspectesValider seulement dans le navigateur
Administration utilisateurRôle, droits, ID immuable, champs autorisésCacher un champ dans l’UI mais l’accepter dans l’API
Réservation ou pré-paiementDate, quantité, téléphone, adresse, stockCréer deux réservations après double clic

Un prompt utile pour Claude Code:

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.

Ce prompt force Claude Code à traiter l’échec: type des valeurs, frontière serveur, affichage, double submit, accessibilité et tests.

Schema Zod copiable

Le schema renvoie des clés de message. L’API n’a pas besoin de connaître la phrase finale; l’interface la traduit.

// 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 est un nombre pour éviter la dérive de type. Un champ HTML number peut devenir une string si l’enregistrement est mal fait. Utilisez valueAsNumber côté React Hook Form, ou un schema API séparé avec z.coerce.number() pour des données brutes.

Validation serveur et erreurs API normalisées

Le client améliore l’expérience; le serveur protège le système. Le body reste unknown tant que Zod ne l’a pas accepté.

// 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);
}

La même forme d’erreur simplifie l’interface: elle lit path et message, peu importe la source.

Composant React Hook Form accessible

Ce composant empêche le double submit, renvoie les erreurs API vers les champs et relie chaque message au champ concerné.

// 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>
  );
}

Dans une interface française, ajoutez aussi les labels au dictionnaire. La logique d’erreur doit rester identique.

Tests, pièges et retour d’expérience

// 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();
});

Les pièges les plus fréquents sont la validation client uniquement, l’oubli du double submit, la dérive de type sur nombres et checkbox, l’erreur serveur non affichée, l’accessibilité réduite à une couleur rouge et les modifications trop larges demandées à Claude Code.

Valeur business

Un formulaire est une partie du tunnel de conversion. S’il échoue, le trafic SEO et publicitaire est gaspillé. S’il explique mal l’erreur, l’utilisateur abandonne. La formation et consultation Claude Code de ClaudeCodeLab peut aider à cadrer les prompts, schemas, tests et revues sur votre dépôt réel.

Lors du test de Masa, la première version générée avait une bonne UI, mais seats arrivait comme string et l’erreur de domaine bloqué ne revenait pas dans le champ email. valueAsNumber, la normalisation API et les tests Testing Library ont rendu ces problèmes reproductibles et corrigibles. Les message keys ont aussi facilité la vérification des interfaces japonaise et anglaise avec la même réponse serveur.

Résumé

Avec Claude Code, traitez la validation comme une frontière complète: React Hook Form, Zod, serveur, erreurs API, accessibilité, i18n et tests. Le bon formulaire n’est pas seulement rapide à générer. Il reste compréhensible quand l’utilisateur se trompe ou quand le serveur refuse les données.

#Claude Code #validation #forms #Zod #React
Gratuit

PDF gratuit: cheatsheet Claude Code

Saisissez votre email et téléchargez une page avec commandes, habitudes de review et workflow sûr.

Nous protégeons vos données et n'envoyons pas de spam.

Masa

À propos de l'auteur

Masa

Ingénieur spécialisé dans les workflows pratiques avec Claude Code.