React Hook Form + Zod

Formulaires performants et validation TypeScript-first

Utilisez les flèches, cliquez ou glissez pour naviguer

Objectifs de la leçon

1. Comprendre React Hook Form

Pourquoi c'est plus performant que les controlled inputs

2. Maîtriser le trio de base

useForm, register, handleSubmit

3. Valider avec Zod

Schémas, z.infer, zodResolver

4. Gérer les erreurs

Affichage ergonomique, UX

5. Formulaires complexes

useFieldArray, Controller

6. Live coding

Formulaire d'inscription complet

Plan du cours

1

Pourquoi React Hook Form ?

Le problème des controlled inputs et des re-renders

2

useForm, register, handleSubmit

Le trio de base pour créer un formulaire

3

Validation avec Zod

Schémas, z.infer, @hookform/resolvers

4

Erreurs de validation

Affichage, messages personnalisés, UX

5

Live coding

Formulaire d'inscription complet avec Zod + React Hook Form

Installation

# React Hook Form

npm install react-hook-form

C'est tout! 🎉

React Hook Form ne nécessite aucune configuration supplémentaire

💡 On installera zod et @hookform/resolvers plus tard pour la validation

Le problème des controlled inputs

Chaque frappe = un re-render

// Controlled input classique ❌

const [value, setValue] = useState('');

<input

value={value}

onChange={(e) => setValue(e.target.value)}

/>

⚠️ Problème

À chaque caractère tapé, le composant entier se re-render

Imaginez un formulaire avec 20 champs... 😱

Visualisation des re-renders

❌ Controlled Input

Touche "a" → re-render

Touche "b" → re-render

Touche "c" → re-render

...

100 caractères = 100 re-renders!

✅ React Hook Form

Touche "a" → pas de re-render

Touche "b" → pas de re-render

Touche "c" → pas de re-render

...

Re-render uniquement à la soumission!

💡 React Hook Form utilise des refs (uncontrolled) au lieu du state

Uncontrolled vs Controlled

Controlled

React gère la valeur via useState

<input value={value} onChange={...} />

⚠️ Re-render à chaque changement

Uncontrolled

Le DOM gère la valeur via ref

<input ref={inputRef} />

✅ Pas de re-render pendant la saisie

🎯 React Hook Form = Uncontrolled par défaut

Les valeurs sont lues uniquement quand on en a besoin (soumission)

Le trio de base

useForm, register, handleSubmit

useForm

Initialiser le formulaire

register

Connecter les inputs

handleSubmit

Gérer la soumission

useForm : Initialiser le formulaire

import { useForm } from 'react-hook-form';

const {

register,

handleSubmit,

formState: { errors }

} = useForm();

Ce que useForm retourne

register — Fonction pour connecter les inputs

handleSubmit — Wrapper pour la soumission

formState — État du formulaire (errors, isSubmitting...)

register : Connecter les inputs

La fonction magique qui lie l'input au formulaire

// Syntaxe de base

<input {...register('email')} />

// Avec validation

<input

{...register('email', {

required: 'Email requis',

pattern: {

value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,

message: 'Email invalide'

}

})}

/>

💡 ...register('name') spread les props (onChange, onBlur, ref, name)

handleSubmit : Gérer la soumission

const onSubmit = (data) => {

console.log(data);

// { email: "[email protected]", password: "..." }

};

<form onSubmit={handleSubmit(onSubmit)}>

Ce que fait handleSubmit

1️⃣ Empêche le rechargement de la page (e.preventDefault)

2️⃣ Valide tous les champs

3️⃣ Si valide → appelle onSubmit avec les données

4️⃣ Si invalide → affiche les erreurs

Premier formulaire complet

import { useForm } from 'react-hook-form';

function LoginForm() {

const { register, handleSubmit, formState: { errors } } = useForm();

const onSubmit = (data) => console.log(data);

return (

<form onSubmit={handleSubmit(onSubmit)}>

<input {...register('email', { required: 'Requis' })} />

{errors.email && <span>{errors.email.message}</span>}

<input {...register('password', { minLength: 6 })} />

<button type="submit">Connexion</button>

</form>

);

}

✅ Formulaire fonctionnel en ~15 lignes!

Validation avec Zod

TypeScript-first schema validation

Définir le schéma ET inférer le type automatiquement

Pourquoi Zod ?

❌ Sans Zod

• Validation manuelle dans register

• Types TypeScript séparés

• Risque de désynchronisation

• Maintenance difficile

✅ Avec Zod

• Schéma unique comme source de vérité

• Types auto-générés avec z.infer

• Validation runtime + compile-time

• Messages d'erreur personnalisés

🎯 Un seul schéma = Type TypeScript + Validation runtime

Installation

# Zod + Resolver pour React Hook Form

npm install zod @hookform/resolvers

zod

Bibliothèque de validation de schémas

@hookform/resolvers

Connecte Zod à React Hook Form

Créer un schéma Zod

import { z } from 'zod';

const schema = z.object({

email: z.string().email('Email invalide'),

password: z.string().min(6, 'Min 6 caractères'),

age: z.number().min(18, 'Majorité requise'),

});

z.string()

Chaîne de caractères

z.number()

Nombre

z.boolean()

Booléen

Méthodes de validation courantes

Strings

.min(3) → minimum 3 caractères

.max(100) → maximum 100

.email() → format email

.url() → format URL

.regex(/.../) → pattern custom

Numbers

.min(0) → valeur minimum

.max(100) → valeur maximum

.int() → entier uniquement

.positive() → positif uniquement

💡 Toutes les méthodes acceptent un message custom : .min(3, 'Message')

Inférer le type TypeScript

Le schéma Zod génère automatiquement le type!

const schema = z.object({

email: z.string().email(),

password: z.string().min(6),

});

type FormData = z.infer<typeof schema>;

// Équivalent à :

// type FormData = { email: string; password: string }

🎯 Plus jamais de double définition!

Le schéma est la source unique de vérité

Connecter Zod avec zodResolver

import { zodResolver } from '@hookform/resolvers/zod';

const { register, handleSubmit, formState } = useForm<FormData>({

resolver: zodResolver(schema),

defaultValues: {

email: '',

password: '',

}

});

✅ La validation est maintenant automatique!

Plus besoin de règles dans register()

Formulaire complet avec Zod

import { useForm } from 'react-hook-form';

import { zodResolver } from '@hookform/resolvers/zod';

import { z } from 'zod';

const schema = z.object({

email: z.string().email('Email invalide'),

password: z.string().min(6, '6 caractères min'),

});

type FormData = z.infer<typeof schema>;

function Form() {

const { register, handleSubmit, formState: { errors } } =

useForm<FormData>({ resolver: zodResolver(schema) });

return (

<form onSubmit={handleSubmit(data => console.log(data))}>

<input {...register('email')} />

{errors.email && <p>{errors.email.message}</p>}

<input {...register('password')} />

{errors.password && <p>{errors.password.message}</p>}

</form>

);

}

Erreurs de validation

Affichage ergonomique et UX

L'objet errors contient toutes les erreurs

L'objet errors

const { formState: { errors } } = useForm();

// Structure de errors :

{

email: {

type: 'invalid_string',

message: 'Email invalide'

},

password: undefined // Pas d'erreur

}

Accès aux erreurs

errors.email?.message — Message de l'erreur

errors.email?.type — Type de l'erreur

Affichage des erreurs

❌ Basique

{errors.email && (

<span>{errors.email.message}</span>

)}

✅ Avec style

{errors.email && (

<p className="text-red-500 text-sm mt-1">

{errors.email.message}

</p>

)}

💡 Affichez l'erreur juste sous le champ concerné pour une meilleure UX

UX : Quand valider ?

✅ mode: 'onBlur' (recommandé)

Validation quand l'utilisateur quitte le champ

useForm({ mode: 'onBlur' })

⚠️ mode: 'onChange'

Validation à chaque frappe — peut être agaçant

💡 mode: 'onTouched'

Validation après première interaction

❌ Éviter : mode: 'onChange' partout

L'utilisateur voit des erreurs avant même de finir de taper

Messages personnalisés

const schema = z.object({

password: z.string()

.min(8, 'Au moins 8 caractères')

.max(100, 'Maximum 100 caractères')

.regex(/[A-Z]/, 'Une majuscule requise')

.regex(/[0-9]/, 'Un chiffre requis'),

});

✅ Messages clairs et actionables pour l'utilisateur

Formulaires complexes

useFieldArray et Controller

useFieldArray

Champs dynamiques

Controller

Composants custom

useFieldArray : Champs dynamiques

Ajouter/supprimer des lignes dynamiquement

Ex: Liste de compétences, plusieurs adresses...

const { fields, append, remove } = useFieldArray({

control,

name: 'skills'

});

💡 control vient de useForm : const { control } = useForm()

Exemple useFieldArray

const { fields, append, remove } = useFieldArray({ control, name: 'skills' });

return (

<>

{fields.map((field, index) => (

<div key={field.id}>

<input {...register(`skills.${index}.name`)} />

<button onClick={() => remove(index)}>Supprimer</button>

</div>

))}

<button onClick={() => append({ name: '' })}>

Ajouter une compétence

</button>

</>

);

✅ append() ajoute, remove(index) supprime

Controller : Composants custom

Pour les composants qui ne propagent pas les refs

Ex: React Select, DatePicker, Slider...

<Controller

name="category"

control={control}

render={({ field }) => (

<Select {...field} options={options} />

)}

/>

🎯 Controller wrappe le composant et gère la connexion

register vs Controller

register

Inputs natifs HTML

✅ <input />

✅ <select />

✅ <textarea />

✅ <input type="checkbox" />

<input {...register('name')} />

Controller

Composants custom

✅ React Select

✅ Material UI DatePicker

✅ Slider custom

✅ Tout composant tierce

<Controller name="..." control={control} ... />

⚠️ Pièges courants

❌ Oublier le ...register('name')

// Le champ n'est pas connecté au formulaire!

<input name="email" /> // ❌ Pas connecté

<input {...register('email')} /> // ✅

❌ Confondre register et Controller

register → inputs natifs | Controller → composants custom

❌ Ne pas utiliser z.infer

type FormData = { email: string }; // ❌ Double définition

type FormData = z.infer<typeof schema>; // ✅

⚠️ Le piège du mode onChange

❌ mode: 'onChange'

Validation à chaque frappe

"t" → Erreur: Email invalide

"te" → Erreur: Email invalide

"tes" → Erreur: Email invalide

UX frustrante!

✅ mode: 'onBlur'

Validation à la sortie du champ

L'utilisateur tape tranquillement

Il quitte le champ

→ Validation + erreur si besoin

UX optimale!

💻 Live Coding

Formulaire d'inscription complet

React Hook Form + Zod + Validation + Erreurs

Schéma d'inscription

const registerSchema = z.object({

firstName: z.string().min(2, 'Min 2 caractères'),

lastName: z.string().min(2, 'Min 2 caractères'),

email: z.string().email('Email invalide'),

password: z.string()

.min(8, '8 caractères min')

.regex(/[A-Z]/, 'Une majuscule')

.regex(/[0-9]/, 'Un chiffre'),

confirmPassword: z.string(),

acceptTerms: z.boolean().refine(v => v, 'Requis'),

}).refine(data => data.password === data.confirmPassword, {

message: 'Les mots de passe ne correspondent pas',

path: ['confirmPassword']

});

Composant d'inscription

function RegisterForm() {

const { register, handleSubmit, formState: { errors, isSubmitting } } =

useForm<RegisterData>({

resolver: zodResolver(registerSchema),

mode: 'onBlur'

});

const onSubmit = async (data: RegisterData) => {

await registerUser(data);

};

return (

<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">

<input {...register('firstName')} placeholder="Prénom" />

{errors.firstName && <p className="text-red-500">{errors.firstName.message}</p>}

// ... autres champs

<button disabled={isSubmitting}>

{isSubmitting ? 'Inscription...' : 'S\'inscrire'}

</button>

</form>

);

}

Points clés à retenir

Refs vs State

React Hook Form utilise des refs → pas de re-render à chaque frappe

Schéma unique

Zod définit le schéma ET infère le type TypeScript avec z.infer

zodResolver

Connecte Zod à React Hook Form — validation automatique

useFieldArray

Pour les champs dynamiques (ajouter/supprimer des lignes)

À retenir !

useForm

Initialise le formulaire

register

Connecte les inputs natifs

handleSubmit

Gère la soumission

zodResolver

Connecte Zod

⚠️ Préférez mode: 'onBlur' pour une meilleure UX

Questions?

Formulaires performants avec React Hook Form + Zod