Formulaires et validation

Formulaires accessibles avec react-hook-form, zod et shadcn/ui Form, incluant les erreurs en ligne, les états en attente et désactivés, les retours de succès et la validation côté serveur avec des schémas partagés.

Qu'est-ce que Formulaires et validation ?

Cette compétence fournit une approche structurée pour créer des formulaires web accessibles et robustes en utilisant `react-hook-form` comme gestionnaire de formulaire, `zod` pour la validation de schéma, et les composants `Form` de shadcn/ui pour l'interface utilisateur. L'idée centrale est d'avoir un seul schéma zod comme source de vérité unique pour les types TypeScript, la validation côté client et la validation côté serveur, garantissant cohérence et sécurité. Les règles couvrent des aspects essentiels comme la validation au moment du blur initial (mode `onTouched`), l'accessibilité avec des labels visibles et des messages d'erreur utiles, la gestion des états de soumission (en attente avec spinner, désactivé pour éviter les doubles envois), et le traitement des erreurs serveur (retour 422 avec `fieldErrors`). La compétence inclut des exemples concrets de code (schémas, composants de formulaire) et des notes pratiques sur l'utilisation de `noValidate`, `defaultValues`, et l'intégration avec TanStack Query pour les mutations. Elle convient aux applications full-stack avec Next.js, Remix, ou toute plateforme React utilisant la stack mentionnée.

Compatible avec~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/ko-system/kodra-skills/tree/HEAD/skills/forms-validation

Installed? Explore more Développement et programmation skills: steipete/bluebubbles, steipete/eightctl, steipete/blucli · View all 6 →

Demander à votre IA préférée

Ouvre une nouvelle conversation avec cette compétence d'agent déjà préchargée.

Documentation

Que fait Formulaires et validation ?

Stack : react-hook-form + zod + @hookform/resolvers/zod + shadcn/ui Composants Form (déjà installés dans le canevas KODRA). Un seul schéma zod est t la seule source de vérité pour les types, la validation côté client et la validation côté serveur.

Règles

  1. D'abord le schéma. Définir un schéma zod ; dériver le type TypeScript avec z.infer. Placer les schémas partagés dans src/lib/schemas/<nom>.ts pour que les routes serveur puissent importer le même fichier.
  2. Valider aussi côté serveur. La validation client est pour l'UX ; la route serveur, la fonction edge ou la contrainte de base de données est la limite de sécurité. Ré-analyser le corps avec le même schéma et retourner les erreurs de champ.
  3. Chaque champ a un label visible, une description utile lorsque le format n'est pas évident, et une erreur en ligne sous le champ.
  4. Moment de validation : mode: "onTouched" (valider après le premier blur, puis à chaque changement). Ne pas crier sur les utilisateurs pendant qu'ils tapent la première fois.
  5. Le bouton de soumission reste activé jusqu'au début de la soumission, puis affiche un état en attente et est désactivé pour éviter les doubles envois.
  6. En cas de succès montrer ce qui s'est passé (toast ou message en ligne) et réinitialiser ou naviguer. En cas d'échec garder la saisie de l'utilisateur, afficher l'erreur près du formulaire, et déplacer le focus vers le premier champ invalide (react-hook-form le fait par défaut).
  7. Utiliser le bon input : type="email", inputMode="numeric", autoComplete tokens (name, email, tel, street-address, postal-code, one-time-code, new-password), pour que les navigateurs et téléphones aident.

Formulaire canonique

// src/lib/schemas/contact.ts
import { z } from "zod";

export const contactSchema = z.object({
  name: z.string().trim().min(2, "Saisissez votre nom complet"),
  email: z.string().trim().email("Saisissez un email valide, comme [email protected]"),
  phone: z
    .string()
    .trim()
    .regex(
      /^0\d{8,9}$/,
      "Saisissez un numéro de téléphone thaïlandais commençant par 0 (9-10 chiffres)",
    )
    .optional()
    .or(z.literal("")),
  topic: z.enum(["booking", "billing", "other"], {
    required_error: "Choisissez un sujet",
  }),
  message: z
    .string()
    .trim()
    .min(10, "Parlez-nous un peu plus (au moins 10 caractères)")
    .max(2000),
  consent: z.boolean().refine((v) => v, "Veuillez accepter pour continuer"),
});
export type ContactInput = z.infer<typeof contactSchema>;
// src/components/ContactForm.tsx
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { contactSchema, type ContactInput } from "@/lib/schemas/contact";

export function ContactForm() {
  const form = useForm<ContactInput>({
    resolver: zodResolver(contactSchema),
    mode: "onTouched",
    defaultValues: {
      name: "",
      email: "",
      phone: "",
      message: "",
      consent: false,
    },
  });

  async function onSubmit(values: ContactInput) {
    const res = await fetch("/api/contact", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(values),
    });
    if (res.status === 422) {
      const { fieldErrors } = (await res.json()) as {
        fieldErrors: Partial<Record<keyof ContactInput, string>>;
      };
      for (const [field, message] of Object.entries(fieldErrors)) {
        form.setError(field as keyof ContactInput, { type: "server", message });
      }
      return;
    }
    if (!res.ok) {
      form.setError("root", {
        message:
          "Nous n'avons pas pu envoyer votre message. Vérifiez votre connexion et réessayez.",
      });
      return;
    }
    toast.success("Message envoyé. Nous répondrons sous un jour ouvré.");
    form.reset();
  }

  const { isSubmitting, errors } = form.formState;

  return (
    <Form {...form}>
      <form
        onSubmit={form.handleSubmit(onSubmit)}
        noValidate
        className="grid gap-5 sm:grid-cols-2"
      >
        <FormField
          control={form.control}
          name="name"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Nom complet</FormLabel>
              <FormControl>
                <Input autoComplete="name" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email</FormLabel>
              <FormControl>
                <Input type="email" autoComplete="email" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="topic"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Sujet</FormLabel>
              <Select onValueChange={field.onChange} value={field.value}>
                <FormControl>
                  <SelectTrigger>
                    <SelectValue placeholder="Choisissez un sujet" />
                  </SelectTrigger>
                </FormControl>
                <SelectContent>
                  <SelectItem value="booking">Réservation</SelectItem>
                  <SelectItem value="billing">Facturation</SelectItem>
                  <SelectItem value="other">Autre chose</SelectItem>
                </SelectContent>
              </Select>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="phone"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Téléphone (optionnel)</FormLabel>
              <FormControl>
                <Input
                  type="tel"
                  inputMode="tel"
                  autoComplete="tel"
                  {...field}
                />
              </FormControl>
              <FormDescription>Seulement si vous préférez un rappel.</FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="message"
          render={({ field }) => (
            <FormItem className="sm:col-span-2">
              <FormLabel>Message</FormLabel>
              <FormControl>
                <Textarea rows={5} {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="consent"
          render={({ field }) => (
            <FormItem className="flex items-start gap-3 sm:col-span-2">
              <FormControl>
                <Checkbox
                  checked={field.value}
                  onCheckedChange={field.onChange}
                />
              </FormControl>
              <div className="space-y-1 leading-none">
                <FormLabel>
                  J'accepte d'être contacté·e à propos de cette demande
                </FormLabel>
                <FormMessage />
              </div>
            </FormItem>
          )}
        />
        {errors.root && (
          <p role="alert" className="text-sm text-destructive sm:col-span-2">
            {errors.root.message}
          </p>
        )}
        <div className="sm:col-span-2">
          <Button
            type="submit"
            disabled={isSubmitting}
            className="w-full sm:w-auto"
          >
            {isSubmitting && (
              <Loader2
                className="mr-2 h-4 w-4 animate-spin"
                aria-hidden="true"
              />
            )}
            {isSubmitting ? "Envoi en cours..." : "Envoyer le message"}
          </Button>
        </div>
      </form>
    </Form>
  );
}

Notes :

  • noValidate désactive les bulles natives du navigateur pour que seuls les messages zod soient affichés.
  • Toujours passer defaultValues (chaînes vides, pas undefined) pour que les inputs soient contrôlés dès le premier rendu.
  • Utiliser value={field.value} sur Select/RadioGroup (pas defaultValue) pour que form.reset() les efface.
  • Lorsque le formulaire est soumis via une mutation TanStack Query, utiliser mutation.isPending au lieu de isSubmitting, et appeler mutateAsync dans onSubmit pour que react-hook-form l'attende.

Validation côté serveur

Analyser avec le même schéma et retourner 422 avec les erreurs de champ :

const parsed = contactSchema.safeParse(body);
if (!parsed.success) {
  return Response.json(
    { fieldErrors: parsed.error.flatten().fieldErrors },
    { status: 422 },
  );
}

flatten().fieldErrors donne string[] par champ ; envoyer le premier message (fieldErrors.email?.[0]) ou adapter la boucle client. Les contraintes de base de données (email unique, clés étrangères) sont également mappées en erreurs de champ : capturer la violation unique et retourner { fieldErrors: { email: "Cet email est déjà enregistré" } }. Dans les applications Supabase avec accès RLS uniquement, valider à nouveau dans une contrainte de base de données ou une fonction edge ; ne jamais se fier au client.

Style des messages d'erreur

  • Dire quoi faire, pas ce qui a mal fonctionné : "Saisissez une date après aujourd'hui", pas "Date invalide".
  • Utiliser les termes de l'utilisateur et montrer le format attendu en ligne ("JJ/MM/AAAA").
  • Un message par champ à la fois ; ne pas répéter le label.
  • Écrire les messages dans la langue de l'application ; les messages zod sont de simples chaînes, donc passer le texte traduit (voir la compétence de localisation thaïlandaise).

Liste de vérification des états

  • Vide : labels, espaces réservés utiles (exemples, pas d'instructions).
  • Saisie : aucune erreur jusqu'au premier blur.
  • Invalide : bordure rouge + message sous le champ (FormMessage), le focus se déplace vers la première erreur lors de la soumission.
  • Soumission : bouton désactivé avec spinner et libellé de progression ; les entrées restent lisibles.
  • Erreur serveur : message au-dessus du bouton de soumission avec role="alert" ; les entrées sont conservées.
  • Succès : toast ou confirmation en ligne ; réinitialisation du formulaire ou redirection.
  • Modifications non sauvegardées sur les formulaires longs : avertir avant de naviguer ailleurs (formState.isDirty).

Individual skills in this repo

This repo contains 1 individual skill — each has its own dedicated page.

Skills associés