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.
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’usage | Validation importante | Piège courant |
|---|---|---|
| Essai SaaS | Email professionnel, taille d’équipe, plan, conditions | Ne pas décider si les emails personnels sont acceptés |
| Contact | Nom, email, catégorie, longueur du message, URLs suspectes | Valider seulement dans le navigateur |
| Administration utilisateur | Rôle, droits, ID immuable, champs autorisés | Cacher un champ dans l’UI mais l’accepter dans l’API |
| Réservation ou pré-paiement | Date, quantité, téléphone, adresse, stock | Cré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.
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.
À propos de l'auteur
Masa
Ingénieur spécialisé dans les workflows pratiques avec Claude Code.
Articles liés
Workflow Obsidian vers CLAUDE.md avec Claude Code
Transformer des notes Obsidian en notes CLAUDE.md concises pour reprendre les sessions sans réexpliquer.
Claude Code Revenue CTA Routing : relier articles, PDF, Gumroad et consultation
Un workflow Claude Code pour orienter les lecteurs vers PDF gratuit, Gumroad ou consultation selon l'intention.
Règles de handoff Claude Code en équipe: preuves, permissions, rollback et revenus
Un format concret pour transmettre un travail Claude Code avec preuves, permissions, rollback, PDF gratuit, Gumroad et consultation.