2. Premiers pas avec React
2.1. Exemple 01 : la structure d'une application React
2.1.1. Le fichier package.json
Le fichier [package.json] du dossier des exemples déclare les bibliothèques utilisées et les commandes du projet :
| {
"name": "exemples-react",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "vite --config vite.config.ts",
"build": "vite build --config vite.config.ts",
"check": "tsc --noEmit"
},
"dependencies": {
"@tanstack/react-query": "^5.104.0",
"bootstrap": "^5.3.8",
"i18next": "^26.4.2",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-hook-form": "^7.89.0",
"react-i18next": "^17.0.15",
"react-router": "^8.4.0",
"zustand": "^5.0.15"
},
"devDependencies": {
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"typescript": "~6.0.2",
"vite": "^8.3.0"
}
}
|
Commentons ce fichier :
- ligne 5 : les fichiers [JavaScript] du projet sont des modules ES ([import] / [export]), la norme actuelle du langage ;
- lignes 6-10 : les commandes du projet, lancées par [npm run <nom>] (ou [npm start] pour la commande [start]) :
- [npm start 05-champs-controles] lance [vite --config vite.config.ts 05-champs-controles] : le serveur de développement, pour l'exemple 05 ;
- [npm run build 05-champs-controles] lance [vite build ...] : la version de production de l'exemple 05, dans le dossier [05-champs-controles/dist/] ;
- [npm run check] lance le compilateur [TypeScript] ([tsc]), qui vérifie les types de TOUS les exemples, sans rien produire ([--noEmit]) ;
- lignes 11-21 : les bibliothèques dont l'application a besoin dans le navigateur : [react] (le cœur : composants, état, hooks) et [react-dom] (l'affichage dans une page web), [react-router] (le routeur, exemples 17 et 18), [react-hook-form] (exemple 08), [zustand] (exemple 20), [i18next] et [react-i18next] (les langues, exemple 21), [@tanstack/react-query] (la lecture des données du serveur, exemple 24), et [Bootstrap] pour la mise en forme ;
- lignes 22-28 : les outils de développement : [Vite] ([vite]), son greffon pour [React] ([@vitejs/plugin-react]), le compilateur [TypeScript], et les descriptions des types de [React] pour [TypeScript] ([@types/react], [@types/react-dom]). Ils ne font pas partie de l'application livrée aux utilisateurs.
Le préfixe [^] d'une version ([^19.3.0]) autorise [npm] à installer une version plus récente de la même version majeure (19.x.y) ; le préfixe [~] ([~6.0.2]) n'autorise que les corrections (6.0.x).
Dans un projet ordinaire, ce fichier est créé par la commande [npm create vite@latest nom-du-projet -- --template react-ts], qui fabrique une application [React] complète, prête à être lancée. Les exemples de ce cours ont été écrits à la main, pour pouvoir en réunir vingt-cinq dans un même dossier.
2.1.2. Le fichier vite.config.ts
[Vite] est l'outil de construction recommandé pour une application [React] : un serveur de développement très rapide, et un compilateur de production. Sa configuration, commune à tous les exemples :
| // =========================================================================
// vite.config.ts - la configuration de Vite, commune à tous les exemples
// -------------------------------------------------------------------------
// Vite est l'outil de construction de React : un serveur de développement
// (npm start) et un compilateur de production (npm run build). Chaque
// exemple est un dossier qui a sa propre page index.html ; on donne son
// nom à Vite, qui en fait la RACINE (root) de l'application :
//
// npm start 05-champs-controles -> vite --config vite.config.ts 05-champs-controles
// npm run build 05-champs-controles -> le résultat dans 05-champs-controles/dist/
// =========================================================================
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
// le plugin React : la syntaxe JSX, et le rechargement "à chaud" (une
// modification du code met la page à jour sans perdre l'état affiché)
plugins: [react()],
server: {
port: 5173,
// le PROXY : toute requête du navigateur vers /api/... est transmise
// au serveur JSON RdvMedecins (port 8080). Il ne sert qu'à partir de
// l'exemple 22 (cf. le chapitre "Dialoguer avec le serveur").
proxy: {
'/api': 'http://localhost:8080',
},
},
});
|
- lignes 9-10 (commentaire) : le nom de l'exemple, donné à la commande, devient la racine ([root]) de l'application : [Vite] y cherche la page [index.html], le dossier [public/], et y range le résultat de la construction ([dist/]) ;
- ligne 19 : le greffon [React] : il compile la syntaxe JSX (cf. plus loin) et permet le rechargement à chaud : une modification du code d'un composant est appliquée à la page sans la recharger, en conservant, autant que possible, l'état affiché ;
- ligne 21 : le port du serveur de développement ;
- lignes 25-27 : le proxy, qui ne servira qu'à partir de l'exemple 22 : toute requête du navigateur vers une adresse commençant par [/api] est transmise au serveur JSON [RdvMedecins]. Nous y reviendrons.
2.1.3. Le fichier tsconfig.json
La configuration [TypeScript] commune à tous les exemples :
| {
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"types": ["vite/client"],
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"verbatimModuleSyntax": true,
"erasableSyntaxOnly": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["*/src", "commun", "vite.config.ts"]
}
|
- lignes 3-6 : du code [JavaScript] moderne (ES2023), les définitions du navigateur ([DOM]), et des modules résolus comme le fait l'outil de construction ;
- ligne 7 : les définitions propres à [Vite] (par exemple, l'import d'une feuille de style, [import './App.css'], est permis) ;
- ligne 8 : la syntaxe JSX est acceptée, et compilée pour [React] ;
- lignes 9-13 : le mode strict de [TypeScript] (types vérifiés rigoureusement, [null] et [undefined] compris), et des vérifications supplémentaires : une variable ou un paramètre inutilisé est signalé comme une erreur ;
- ligne 14 : un import qui ne concerne que des types s'écrit [import type { ... }] (ou [import { type X }]) : l'outil de construction sait ainsi qu'il n'existe plus dans le [JavaScript] produit ;
- ligne 16 : on pourra importer un fichier JSON comme un module (exemple 21) ;
- ligne 18 : [tsc] ne fait que vérifier les types ; c'est [Vite] qui compile réellement le code (il se contente de retirer les types, sans les vérifier : d'où la commande [npm run check]) ;
- ligne 20 : les fichiers vérifiés : ceux de tous les exemples.
L'éditeur [VS Code] utilise cette configuration : une faute de frappe dans un composant ([{titr}] au lieu de [{titre}]) est soulignée en rouge dès qu'on la tape.
2.1.4. La page index.html
Chaque exemple a sa page HTML. Celle de l'exemple 01 :
| <!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Exemple 01 - une première application</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<!-- l'application React s'affiche dans cet élément (cf. src/main.tsx) -->
<div id="root"></div>
<!-- le point d'entrée : un module JavaScript, compilé par Vite à la volée -->
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
|
C'est la seule page que le navigateur chargera. Elle est presque vide :
- ligne 10 : l'élément dans lequel [React] affichera l'application ;
- ligne 12 : le point d'entrée de l'application, un module [TypeScript]. Le navigateur ne comprend pas [TypeScript] : c'est [Vite] qui compile ce fichier, et ceux qu'il importe, au moment où le navigateur les demande.
On n'y trouve aucune balise [<link>] : les feuilles de style sont importées par le code (cf. [main.tsx]), et [Vite] les ajoute lui-même à la page.
2.1.5. Le point d'entrée : main.tsx
| // =========================================================================
// 01-premiere-application/src/main.tsx - le point d'entrée de l'application
// -------------------------------------------------------------------------
// index.html charge ce fichier. Il démarre l'application React :
// 1. createRoot(élément) : React prend possession de l'élément <div id="root">
// de la page ;
// 2. root.render(<App />) : il y affiche le composant RACINE, App.
// Les feuilles de style sont des modules comme les autres : on les importe.
// =========================================================================
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import 'bootstrap/dist/css/bootstrap.min.css';
import App from './App';
// le "!" dit à TypeScript : cet élément existe (il est dans index.html)
createRoot(document.getElementById('root')!).render(
// StrictMode : en développement seulement, React fait des vérifications
// supplémentaires (cf. exemple 13). Il n'affiche rien lui-même.
<StrictMode>
<App />
</StrictMode>,
);
|
Un fichier [.tsx] est un fichier [TypeScript] qui contient du JSX.
- ligne 12 : la fonction de [react-dom] qui démarre une application dans une page web ;
- ligne 13 : la feuille de style de [Bootstrap] : un import sans nom, qui ne sert qu'à l'ajouter à la page ;
- ligne 14 : le composant racine de l'application, la fonction exportée par [App.tsx] ;
- ligne 17 : [React] prend possession de l'élément [<div id="root">]. [document.getElementById('root')] peut renvoyer [null] pour [TypeScript] : le [!] affirme qu'il existe ;
- lignes 20-22 : [render(...)] affiche le composant [App] dans cet élément. [<App />] est du JSX : l'utilisation du composant [App], comme une balise. [<StrictMode>] est un composant de [React] qui n'affiche rien lui-même, mais active des vérifications supplémentaires en développement (exemple 13).
2.1.6. Le composant racine : App.tsx et App.css
Un composant [React] est une fonction :
| // =========================================================================
// 01-premiere-application/src/App.tsx - un premier composant
// -------------------------------------------------------------------------
// Un composant React est une FONCTION, dont le nom commence par une
// majuscule, qui RENVOIE ce qu'il faut afficher, écrit en JSX : du HTML
// dans le code TypeScript. React appelle cette fonction pour fabriquer la
// page ; il la RAPPELLE chaque fois que l'état du composant change, et met
// à jour la page avec le nouveau résultat.
// =========================================================================
import { useState } from 'react';
// une feuille de style, importée par le composant qui l'utilise
import './App.css';
// des données "ordinaires", hors du composant : elles ne changent jamais
const titre = 'Bienvenue dans React';
const langages = ['TypeScript', 'HTML', 'CSS'];
export default function App() {
// useState(0) : une donnée d'ÉTAT, de valeur initiale 0. On reçoit sa
// valeur ACTUELLE (compteur) et la fonction qui la change (setCompteur).
// Appeler setCompteur demande à React de RAPPELER App pour mettre à jour
// la page. On ne modifie jamais la page soi-même.
const [compteur, setCompteur] = useState(0);
const [heure, setHeure] = useState(() => new Date().toLocaleTimeString());
// une fonction appelée par le bouton
function incrementer() {
setCompteur((n) => n + 1);
setHeure(new Date().toLocaleTimeString());
}
// le JSX renvoyé : { expression } insère la valeur d'une expression ;
// className remplace l'attribut HTML class (un mot réservé en JavaScript)
return (
<div className="container py-4">
<h1 className="h3">{titre}</h1>
<p>Cette page est fabriquée dans le navigateur, par React, à partir de index.html, main.tsx et du composant App.</p>
<p>
On y écrit du {langages.join(', ')} ({langages.length} langages).
</p>
{/* onClick : la fonction à appeler quand on clique (cf. exemple 04) */}
<button className="btn btn-primary" onClick={incrementer}>
Cliquez-moi
</button>
<p className="mt-3 compteur">
Vous avez cliqué {compteur} fois (dernier clic : {heure}).
</p>
</div>
);
}
|
- ligne 11 : le hook [useState], fourni par [React] ;
- ligne 13 : la feuille de style du composant ;
- lignes 16-17 : deux données ordinaires, déclarées hors du composant : elles ne changent jamais ;
- ligne 19 : le composant : une fonction dont le nom commence par une majuscule (c'est ainsi que le JSX distingue un composant, [<App />], d'un élément HTML, [<div>]). [export default] : c'est ce que le module exporte par défaut, importé dans [main.tsx] par [import App from './App'] ;
- ligne 24 : [useState(0)] crée une donnée d'état, de valeur initiale 0. Il renvoie un tableau de deux éléments, que l'on « déstructure » : la valeur actuelle ([compteur]) et la fonction qui la change ([setCompteur]). L'état est mémorisé par [React] : il survit d'un appel de la fonction à l'autre ;
- ligne 25 : on peut donner à [useState] une fonction qui calcule la valeur initiale : elle n'est appelée qu'au premier affichage ;
- lignes 28-30 : une fonction appelée par le bouton. Elle ne touche pas à la page : elle change l'état. [setCompteur((n) => n + 1)] calcule la nouvelle valeur à partir de l'ancienne. Chaque appel d'une fonction [setXxx] demande à [React] de rappeler [App] ; [React] compare le nouveau résultat à l'ancien et ne modifie, dans la page, que ce qui a changé ;
- lignes 35-51 : le JSX renvoyé, c'est-à-dire ce qu'il faut afficher :
- le JSX ressemble au HTML, avec quelques différences : l'attribut [class] s'écrit [className] ([class] est un mot réservé de [JavaScript]), chaque balise doit être fermée ([<br />], [<input />]), et le tout doit avoir UN élément racine ;
- ligne 37 : [{ expression }] insère la valeur d'une expression [TypeScript]. Elle est échappée : si [titre] contenait [<b>], on verrait [<b>] s'afficher, sans que la balise soit interprétée ;
- ligne 40 : l'expression peut être quelconque : un appel de méthode, un calcul ;
- ligne 43 : un commentaire, en JSX, s'écrit [{/* ... */}] ;
- ligne 44 : un clic sur le bouton appelle la fonction [incrementer] (cf. exemple 04). On donne la fonction elle-même, sans parenthèses : [onClick={incrementer()}] l'appellerait tout de suite, pendant l'affichage ;
- ligne 48 : la valeur actuelle de l'état. Quand l'état change, [App] est rappelée, et ce texte change.
Le JSX n'est pas du HTML : c'est une écriture commode pour des appels de fonction. Le compilateur transforme [<h1 className="h3">{titre}</h1>] en un appel du genre [jsx('h1', { className: 'h3', children: titre })], qui fabrique un objet décrivant l'élément. Le composant renvoie donc un arbre d'objets (le « DOM virtuel »), que [React] compare à l'arbre précédent pour savoir quoi changer dans la vraie page.
Et sa feuille de style :
| /* une feuille de style importée par App.tsx ; ATTENTION : elle s'applique
à toute la page, et non au seul composant App (cf. exemple 10) */
.compteur {
font-weight: bold;
color: #087ea4;
}
|
Contrairement à [Angular] ou [Vue.js], [React] n'isole pas les styles d'un composant : une feuille de style importée s'applique à toute la page. L'exemple 10 montrera les « modules CSS », qui limitent une classe à un composant.
2.1.7. Exécution
Dans la console ouverte sur le dossier [exemples-react] :
npm start 01-premiere-application
La console affiche :
| > exemples-react@1.0.0 start
> vite --config vite.config.ts 01-premiere-application
VITE v8.3.1 ready in 400 ms
➜ Local: http://localhost:5173/
➜ Network: use --host to expose
|
On ouvre l'adresse http://localhost:5173/ et l'on clique trois fois sur le bouton :

Deux expériences à faire :
- modifier le texte du titre dans [App.tsx] et enregistrer le fichier : la page change aussitôt, sans que l'on ait à la recharger, et le compteur garde sa valeur. Le serveur de développement surveille les fichiers, recompile ce qui a changé et prévient le navigateur ;
- afficher le code source de la page (Ctrl-U) : on n'y trouve que la page [index.html], presque vide. Le HTML affiché a été fabriqué par [React], dans le navigateur. Pour le voir, il faut l'inspecteur des outils de développement (F12, onglet Éléments), ou l'extension [React Developer Tools], qui montre en plus les composants et leur état.
2.2. Exemple 02 : l'état et les valeurs calculées
L'état est le cœur de [React]. Cet exemple calcule le prix TTC d'un article :
| // =========================================================================
// 02-etat/src/App.tsx - l'état, les valeurs calculées, useEffect
// -------------------------------------------------------------------------
// Un petit calculateur de prix TTC :
// - useState : une valeur d'ÉTAT ; la changer (setXxx) fait rappeler
// le composant, qui fabrique la page avec la nouvelle valeur ;
// - une valeur CALCULÉE à partir de l'état n'est PAS un état : c'est une
// simple variable, recalculée à chaque appel du composant ;
// - useMemo : la même chose, mais le calcul n'est refait que si ses
// dépendances ont changé (utile pour un calcul coûteux) ;
// - useEffect : du code exécuté APRÈS l'affichage, quand certaines
// valeurs ont changé : un "effet de bord" (agir sur autre
// chose que la page : ici, le titre de l'onglet).
// =========================================================================
import { useEffect, useMemo, useState } from 'react';
// un formateur de montants en euros, à la française (1 234,50 €)
const euros = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' });
export default function App() {
const [libelle, setLibelle] = useState('Stylo');
const [prixHT, setPrixHT] = useState(2.5);
const [quantite, setQuantite] = useState(1);
const [tauxTVA, setTauxTVA] = useState(20); // en %
// des valeurs CALCULÉES : de simples constantes, recalculées à chaque
// appel du composant. Elles sont toujours à jour, sans rien à synchroniser
const totalHT = quantite * prixHT;
const montantTVA = (totalHT * tauxTVA) / 100;
// useMemo(calcul, [dépendances]) : même résultat, mais React ne refait le
// calcul que si l'une des dépendances a changé depuis l'appel précédent
const totalTTC = useMemo(() => totalHT + montantTVA, [totalHT, montantTVA]);
// l'historique des totaux TTC : il dépend des valeurs SUCCESSIVES du
// total. On mémorise l'ancien total dans l'état ; s'il diffère du
// nouveau, on ajuste l'état PENDANT l'appel du composant (React
// recommence alors aussitôt l'appel, avant d'afficher quoi que ce soit)
const [historique, setHistorique] = useState<{ id: number; texte: string }[]>([]);
const [ancienTTC, setAncienTTC] = useState(totalTTC);
if (totalTTC !== ancienTTC) {
setAncienTTC(totalTTC);
const texte = `${euros.format(ancienTTC)} -> ${euros.format(totalTTC)}`;
// chaque ligne a un numéro unique (id), qui l'identifie (cf. key, exemple 03)
setHistorique((h) => [{ id: (h[0]?.id ?? 0) + 1, texte }, ...h].slice(0, 5));
}
// useEffect(effet, [dépendances]) : l'effet est exécuté APRÈS le premier
// affichage, puis après chaque affichage où totalTTC a changé
useEffect(() => {
document.title = `TTC : ${euros.format(totalTTC)}`;
}, [totalTTC]);
// changer d'article remet la quantité à 1 : on le fait dans le
// gestionnaire de l'événement, là où l'on sait POURQUOI l'état change
function changerLibelle(nouveau: string) {
setLibelle(nouveau);
setQuantite(1);
}
function reinitialiser() {
setLibelle('Stylo');
setPrixHT(2.5);
setQuantite(1);
setTauxTVA(20);
}
return (
<div className="container py-4">
<h1 className="h3 mb-3">Exemple 02 - l'état et les valeurs calculées</h1>
<div className="row g-4">
<div className="col-md-6">
<div className="card p-3">
{/* value + onChange : le champ affiche l'état, et chaque frappe le change (cf. exemple 05) */}
<label className="form-label" htmlFor="libelle">Article</label>
<input id="libelle" className="form-control mb-2" value={libelle}
onChange={(e) => changerLibelle(e.target.value)} />
<label className="form-label" htmlFor="prix">Prix unitaire HT</label>
{/* valueAsNumber : la valeur du champ, en nombre (NaN s'il est vide) */}
<input id="prix" type="number" step="0.1" min="0" className="form-control mb-2" value={prixHT}
onChange={(e) => setPrixHT(e.target.valueAsNumber || 0)} />
<label className="form-label" htmlFor="quantite">Quantité (revient à 1 si l'article change)</label>
<input id="quantite" type="number" min="0" className="form-control mb-2" value={quantite}
onChange={(e) => setQuantite(e.target.valueAsNumber || 0)} />
<label className="form-label" htmlFor="tva">Taux de TVA</label>
{/* la valeur d'un <select> est une CHAÎNE : Number(...) la convertit */}
<select id="tva" className="form-select mb-3" value={tauxTVA}
onChange={(e) => setTauxTVA(Number(e.target.value))}>
<option value={5.5}>5,5 %</option>
<option value={10}>10 %</option>
<option value={20}>20 %</option>
</select>
<button className="btn btn-outline-secondary" onClick={reinitialiser}>Réinitialiser</button>
</div>
</div>
<div className="col-md-6">
<div className="card p-3">
<h2 className="h5">{quantite} x {libelle}</h2>
<table className="table mb-0">
<tbody>
<tr><td>Total HT</td><td className="text-end">{euros.format(totalHT)}</td></tr>
<tr><td>TVA ({tauxTVA} %)</td><td className="text-end">{euros.format(montantTVA)}</td></tr>
<tr className="fw-bold"><td>Total TTC</td><td className="text-end">{euros.format(totalTTC)}</td></tr>
</tbody>
</table>
</div>
<div className="card p-3 mt-3">
<h2 className="h6">Historique du total TTC</h2>
<ul className="mb-0">
{historique.map((ligne) => (
<li key={ligne.id}>{ligne.texte}</li>
))}
</ul>
</div>
</div>
</div>
</div>
);
}
|
Le fichier [main.tsx] de cet exemple, et de la plupart des suivants, est celui de l'exemple 01, sans ses commentaires. Seuls les fichiers [main.tsx] qui diffèrent seront présentés.
- ligne 16 : les hooks utilisés, tous fournis par [React] ;
- ligne 19 : un formateur de montants, à la française (1 234,50 €), créé une fois pour toutes hors du composant. L'exemple 09 présentera l'API [Intl] ;
- lignes 22-25 : quatre données d'état. Leur type est déduit de la valeur initiale : [useState('Stylo')] est un état de type [string], [useState(2.5)] un état de type [number] ;
- lignes 29-30 : deux valeurs calculées à partir de l'état. Ce ne sont PAS des états : de simples constantes, recalculées à chaque appel du composant. Elles sont donc toujours à jour. C'est une règle importante de [React] : on ne range dans l'état que ce qu'on ne peut pas calculer ; tout le reste se calcule pendant l'affichage ;
- ligne 33 : [useMemo(calcul, [dépendances])] donne le même résultat, mais [React] mémorise la valeur et ne refait le calcul que si l'une des dépendances a changé depuis l'appel précédent. Pour une addition, c'est inutile ; on s'en sert pour un calcul coûteux (trier une longue liste...). On l'a mis ici pour montrer sa syntaxe ;
- lignes 39-45 : l'historique des totaux TTC dépend des valeurs successives du total, ce qu'une valeur calculée ne sait pas exprimer. On mémorise l'ancien total dans l'état ([ancienTTC]) ; si, pendant l'affichage, il diffère du nouveau, on ajuste l'état pendant l'appel du composant. [React] le permet : il abandonne alors le résultat en cours et rappelle aussitôt le composant avec le nouvel état, avant d'afficher quoi que ce soit. Chaque ligne de l'historique reçoit un numéro unique, [id], dont l'exemple 03 expliquera l'usage ;
- lignes 50-52 : [useEffect(effet, [dépendances])] : la fonction [effet] est exécutée après l'affichage : une première fois, puis chaque fois que l'une des dépendances (ici [totalTTC]) a changé. On s'en sert pour les effets de bord : agir sur autre chose que ce que le composant affiche (ici, le titre de l'onglet du navigateur ; ailleurs, une minuterie, une requête HTTP) ;
- lignes 56-58 : changer d'article remet la quantité à 1. On le fait dans la fonction qui traite le changement du champ, là où l'on sait pourquoi l'état change. C'est la manière recommandée par [React] : plutôt qu'un effet qui « surveillerait » le libellé, on agit dans le gestionnaire de l'événement ;
- lignes 61-65 : plusieurs changements d'état à la suite. [React] les regroupe : le composant n'est rappelé qu'une fois, à la fin du gestionnaire.
Le JSX :
- lignes 76-77 : un champ contrôlé (cf. exemple 05) : [value] affiche l'état, [onChange] est appelé à chaque frappe, avec l'événement [e] ; [e.target.value] est le texte du champ. En JSX, l'attribut [for] d'une étiquette s'écrit [htmlFor] ;
- lignes 80-81 : pour un champ numérique, [e.target.valueAsNumber] donne la valeur sous forme de nombre ([NaN] si le champ est vide, d'où le [|| 0]) ;
- lignes 87-92 : la valeur d'un [<select>] est toujours une chaîne : [Number(...)] la convertit ;
- ligne 110 : [map] fabrique un élément [<li>] par ligne de l'historique (cf. exemple 03).
Le serveur de développement étant lancé ([npm start 02-etat]), on modifie l'article, le prix, la quantité et le taux :

À chaque changement du total TTC, une ligne s'ajoute à l'historique et le titre de l'onglet change. Changer l'article remet la quantité à 1.
La différence avec [Angular] et [Vue.js] est profonde. Dans ces frameworks, les données sont réactives (signaux, [ref]) : le framework sait quelles données un morceau de page utilise, et ne met à jour que lui. [React] fait plus simple : à chaque changement d'état, il rappelle toute la fonction du composant (et celles de ses enfants), puis compare le résultat au précédent. Le code se lit alors comme une fonction ordinaire : les valeurs calculées sont des variables, les conditions des [if]. Le prix à payer : il faut respecter quelques règles (ne jamais modifier l'état directement, appeler les hooks toujours dans le même ordre), et penser aux effets et à leurs dépendances.
2.3. Exemple 03 : la syntaxe JSX
Le JSX est du HTML écrit dans le code [TypeScript], où tout ce qui est entre accolades est une expression. Cet exemple affiche une liste de médecins, qu'on peut filtrer :
| // =========================================================================
// 03-jsx/src/App.tsx - la syntaxe JSX
// -------------------------------------------------------------------------
// Le JSX est du HTML écrit DANS le code TypeScript. Chaque balise devient
// un appel de fonction, et tout ce qui est entre accolades est une
// expression TypeScript ordinaire :
// - attribut={expression} : la valeur d'un attribut (src, disabled...) ;
// - className={...} : les classes CSS, une chaîne qu'on calcule ;
// - style={{ prop: valeur }} : un OBJET de propriétés CSS (camelCase) ;
// - {condition && <p/>} : un élément qui n'existe que si la
// condition est vraie ;
// - {c ? <A/> : <B/>} : l'un OU l'autre ;
// - {liste.map((x) => <li key={x.id}/>)} : un élément par élément de la
// liste ; key donne l'IDENTITÉ de chacun.
// Pas de syntaxe spéciale pour les conditions et les boucles : c'est du
// TypeScript. Tout ce qui est avant le return est du code ordinaire.
// =========================================================================
import { useState } from 'react';
// une interface TypeScript décrit la forme des objets de la liste
interface Medecin {
id: number;
titre: string;
prenom: string;
nom: string;
specialite: 'généraliste' | 'pédiatre' | 'cardiologue';
disponible: boolean;
}
const MEDECINS: Medecin[] = [
{ id: 1, titre: 'Mme', prenom: 'Marie', nom: 'PELISSIER', specialite: 'généraliste', disponible: true },
{ id: 2, titre: 'Mr', prenom: 'Paul', nom: 'TATOU', specialite: 'pédiatre', disponible: false },
{ id: 3, titre: 'Melle', prenom: 'Sophie', nom: 'DUPONT', specialite: 'cardiologue', disponible: true },
];
// "un bloc parmi plusieurs, selon une valeur" : un switch, dans une fonction
function icone(specialite: Medecin['specialite']) {
switch (specialite) {
case 'pédiatre':
return <span title="enfants">🧸</span>;
case 'cardiologue':
return <span title="cœur">❤️</span>;
default:
return <span title="tous publics">🩺</span>;
}
}
export default function App() {
const [medecins] = useState(MEDECINS);
const [filtre, setFiltre] = useState(''); // texte recherché
const [seulementDisponibles, setSeulementDisponibles] = useState(false);
const [afficherAide, setAfficherAide] = useState(true);
// la liste affichée : une variable ordinaire, recalculée à chaque appel
const texte = filtre.trim().toLowerCase();
const affiches = medecins.filter(
(m) => (!seulementDisponibles || m.disponible) && `${m.prenom} ${m.nom} ${m.specialite}`.toLowerCase().includes(texte),
);
// "l'un des trois blocs" : on le calcule AVANT le return, avec des if
let contenu;
if (medecins.length === 0) {
contenu = <p className="text-muted">Aucun médecin.</p>;
} else if (affiches.length === 0) {
contenu = <p className="text-warning">Aucun médecin ne correspond à « {filtre} ».</p>;
} else {
contenu = (
<table className="table">
<thead>
<tr><th>#</th><th>Médecin</th><th>Spécialité</th><th>Statut</th></tr>
</thead>
<tbody>
{/* une ligne par médecin ; key : l'identité de chaque ligne, pour que
React sache quelle ligne ajouter, déplacer ou supprimer */}
{affiches.map((m, i) => (
<tr key={m.id} className={m.disponible ? '' : 'opacity-50'}>
<td>{i + 1}</td>
<td>{m.titre} {m.prenom} {m.nom}</td>
<td>{icone(m.specialite)} {m.specialite}</td>
<td>
{/* une chaîne de classes calculée par un gabarit de chaîne */}
<span className={`badge ${m.disponible ? 'text-bg-success' : 'text-bg-secondary'}`}>
{m.disponible ? 'disponible' : 'indisponible'}
</span>
</td>
</tr>
))}
</tbody>
</table>
);
}
return (
// <> ... </> : un "fragment", pour renvoyer plusieurs éléments sans les
// envelopper dans un élément HTML supplémentaire
<>
<div className="container py-4">
<h1 className="h3 mb-3">
{/* l'image est dans le dossier public/ de l'exemple : servie telle quelle */}
<img src="/medecin.svg" alt="logo" width={32} height={32} /> Exemple 03 - la syntaxe JSX
</h1>
<div className="d-flex flex-wrap gap-3 align-items-center mb-3">
<input className="form-control w-auto" placeholder="rechercher..." value={filtre}
onChange={(e) => setFiltre(e.target.value)} />
<label className="form-check-label">
<input type="checkbox" className="form-check-input" checked={seulementDisponibles}
onChange={(e) => setSeulementDisponibles(e.target.checked)} /> disponibles seulement
</label>
<button className="btn btn-sm btn-outline-secondary" onClick={() => setAfficherAide((a) => !a)}>
{afficherAide ? "Cacher l'aide" : "Montrer l'aide"}
</button>
</div>
{/* && : le paragraphe n'EXISTE que si afficherAide est vrai */}
{afficherAide && (
<p className="alert alert-info">Tapez un nom ou une spécialité. Les médecins indisponibles sont grisés.</p>
)}
{contenu}
{/* style : un objet ; un nombre seul est compris comme des pixels */}
<p style={{ fontSize: 12 + affiches.length * 2 }}>
{affiches.length} médecin(s) affiché(s) - la taille de ce texte en dépend.
</p>
</div>
</>
);
}
|
- lignes 22-27 : une interface [TypeScript] décrit la forme des objets de la liste ; [specialite] ne peut prendre que trois valeurs ;
- lignes 38-47 : « un bloc parmi plusieurs, selon une valeur ». Le JSX n'a pas de syntaxe pour cela : c'est une fonction ordinaire, avec un [switch], qui renvoie du JSX. [Medecin['specialite']] est le type de la propriété [specialite]. [🧸] est une entité HTML (un emoji), permise dans le JSX ;
- lignes 50-53 : l'état de la page. On ne garde que la valeur de [useState(MEDECINS)], jamais modifiée ici ;
- lignes 56-59 : la liste affichée : une variable, recalculée à chaque appel du composant ;
- lignes 62-68 : un contenu parmi trois, choisi avec des [if] avant le [return]. Un morceau de JSX est une valeur comme une autre : on peut le ranger dans une variable ;
- lignes 76-88 : la liste : [map] transforme chaque médecin en une ligne [<tr>]. [i] est le rang du médecin dans la liste ;
- ligne 77 : l'attribut [key] donne l'identité de chaque ligne. Quand la liste change, [React] s'en sert pour savoir quelle ligne ajouter, déplacer ou supprimer, au lieu de tout refaire. La clé doit être unique parmi les éléments de la liste et stable (la même d'un affichage à l'autre) : un identifiant, pas le rang. [React] signale dans la console une liste sans clés ;
- ligne 77 : les classes CSS sont une chaîne qu'on calcule, ici avec l'opérateur [? :] ;
- ligne 80 : le JSX renvoyé par la fonction [icone] ;
- ligne 83 : un gabarit de chaîne (
***[ ]***[...][ ] ***[) fabrique la liste des classes : ]***badge et une classe calculée.
Le [return] :
- ligne 97 : un fragment, [<>...</>], permet de renvoyer plusieurs éléments sans les envelopper dans un élément HTML supplémentaire (ici, il n'y en a qu'un : c'est pour l'exemple) ;
- ligne 101 : l'image est dans le dossier [public/] de l'exemple : [Vite] la sert telle quelle, à la racine du site. [width={32}] : une valeur numérique, entre accolades ;
- lignes 105-106 : le champ de recherche, contrôlé ; pour une case à cocher, on lit et on écrit [checked] ;
- ligne 111 : une fonction « fléchée » écrite directement dans l'attribut : elle inverse l'état ;
- lignes 117-119 : [condition && <p>...</p>] : le paragraphe n'existe que si la condition est vraie (si elle est fausse, [React] n'affiche rien pour [false], [null] ou [undefined]). Attention au piège : [{liste.length && <p/>}] afficherait « 0 » pour une liste vide ; on écrit [liste.length > 0 && ...] ;
- ligne 121 : le contenu choisi plus haut ;
- ligne 124 : l'attribut [style] reçoit un objet (d'où les doubles accolades : l'expression, puis l'objet), dont les propriétés CSS s'écrivent en « camelCase » ([fontSize] pour [font-size]). Un nombre seul est compris comme des pixels.

On tape « géné » et l'on coche « disponibles seulement », puis on tape « zzz » :

2.4. Exemple 04 : les événements
| // =========================================================================
// 04-evenements/src/App.tsx - réagir aux actions de l'utilisateur
// -------------------------------------------------------------------------
// onEvenement={fonction} : la fonction est appelée quand l'élément émet
// cet événement :
// - onClick={f} appelle f(evenement) ;
// - onClick={() => supprimer(t)} une fonction fléchée, pour passer ses
// propres arguments ;
// - l'événement reçu est un "événement synthétique" de React, qui a la
// même interface que l'événement du navigateur (preventDefault(),
// target, key...) ; evenement.nativeEvent donne l'événement d'origine ;
// - useRef : une RÉFÉRENCE à un élément HTML (ref={champ}), pour lire ou
// agir sur l'élément lui-même (champ.current.value, .focus()...).
// Ici, pas d'état pour le champ de saisie : on le lit nous-mêmes.
// =========================================================================
import { useRef, useState, type FormEvent, type KeyboardEvent, type MouseEvent } from 'react';
interface Tache {
id: number;
libelle: string;
faite: boolean;
}
// le style de la zone cliquable : un objet, comme pour l'attribut style
const styleZone = { background: '#e7f5ff', height: 80, cursor: 'crosshair' };
export default function App() {
const [taches, setTaches] = useState<Tache[]>([
{ id: 1, libelle: 'Installer Node.js', faite: true },
{ id: 2, libelle: 'Lire le chapitre sur les événements', faite: false },
]);
const [journal, setJournal] = useState<string[]>([]);
// une référence au <input> de la nouvelle tâche (null tant qu'il n'est pas affiché)
const champ = useRef<HTMLInputElement>(null);
// une référence peut aussi garder une valeur qui n'est PAS affichée : la
// modifier ne rappelle pas le composant
const prochainId = useRef(3);
function tracer(message: string) {
setJournal((j) => [`${new Date().toLocaleTimeString()} ${message}`, ...j]);
}
// onSubmit : Entrée dans le champ OU clic sur le bouton. preventDefault()
// empêche le navigateur d'envoyer le formulaire (et de recharger la page)
function ajouter(evenement: FormEvent<HTMLFormElement>) {
evenement.preventDefault();
const input = champ.current!;
const libelle = input.value.trim();
if (libelle === '') {
return;
}
// un état qui contient un tableau : on le REMPLACE par un nouveau tableau
// (on ne modifie jamais l'ancien : React ne verrait pas le changement)
setTaches((t) => [...t, { id: prochainId.current++, libelle, faite: false }]);
tracer(`ajout de « ${libelle} »`);
input.value = '';
input.focus();
}
// une touche : evenement.key donne son nom
function clavier(evenement: KeyboardEvent<HTMLInputElement>) {
if (evenement.key === 'Escape') {
evenement.currentTarget.value = '';
}
}
function basculer(tache: Tache, faite: boolean) {
setTaches((t) => t.map((x) => (x.id === tache.id ? { ...x, faite } : x)));
tracer(`« ${tache.libelle} » : ${faite ? 'faite' : 'à faire'}`);
}
function supprimer(tache: Tache) {
setTaches((t) => t.filter((x) => x.id !== tache.id));
tracer(`suppression de « ${tache.libelle} »`);
}
// l'événement d'origine (nativeEvent) a les coordonnées relatives à la zone
function position(evenement: MouseEvent<HTMLDivElement>) {
tracer(`clic en (${evenement.nativeEvent.offsetX}, ${evenement.nativeEvent.offsetY})`);
}
return (
<div className="container py-4">
<h1 className="h3 mb-3">Exemple 04 - les événements</h1>
<form className="d-flex gap-2 mb-3" onSubmit={ajouter}>
<input ref={champ} className="form-control" placeholder="nouvelle tâche (Échap pour vider)" onKeyDown={clavier} />
<button type="submit" className="btn btn-primary">Ajouter</button>
</form>
<ul className="list-group mb-3">
{taches.map((t) => (
<li key={t.id} className="list-group-item d-flex align-items-center gap-2">
{/* e.target : la case à cocher de CETTE ligne ; checked : son état */}
<input type="checkbox" className="form-check-input" checked={t.faite}
onChange={(e) => basculer(t, e.target.checked)} />
<span className={`flex-grow-1 ${t.faite ? 'text-decoration-line-through text-muted' : ''}`}>{t.libelle}</span>
{/* une fonction fléchée : la tâche de CETTE ligne en argument */}
<button className="btn btn-sm btn-outline-danger" onClick={() => supprimer(t)}>Supprimer</button>
</li>
))}
</ul>
<div className="p-3 mb-3 rounded" style={styleZone} onClick={position}>Cliquez dans cette zone</div>
<h2 className="h6">Journal</h2>
<ul className="small">
{journal.map((ligne, i) => (
// clé : le rang de la ligne compté depuis la PREMIÈRE ajoutée (qui a
// toujours la clé 1) : chaque ligne garde la même clé quand d'autres
// arrivent en tête de liste
<li key={journal.length - i}>{ligne}</li>
))}
</ul>
</div>
);
}
|
- ligne 17 : [FormEvent], [KeyboardEvent], [MouseEvent] sont des types d'événements de [React] (d'où le mot [type]) ;
- ligne 26 : le style de la zone cliquable, un objet ;
- ligne 35 : [useRef] crée une référence : un objet [{ current: ... }] que [React] conserve d'un affichage à l'autre. Donnée à l'attribut [ref] d'un élément (ligne 88), elle désigne l'élément HTML lui-même, une fois affiché : [champ.current.value], [champ.current.focus()]... ;
- ligne 38 : une référence peut aussi garder une valeur quelconque, qui n'est pas affichée. La modifier ([prochainId.current++]) ne rappelle pas le composant, à la différence d'un état ;
- lignes 46-59 : appelée par l'événement [onSubmit] du formulaire, émis quand on tape Entrée dans le champ OU qu'on clique sur le bouton [submit] :
- ligne 47 : empêche le navigateur d'envoyer le formulaire au serveur, ce qui rechargerait la page ;
- ligne 48 : l'élément [<input>] ; le [!] affirme qu'il existe (il est affiché) ;
- ligne 55 : un état qui contient un tableau : on le remplace par un nouveau tableau ([[...t, nouvelle]] : les éléments de [t], puis la nouvelle tâche). On ne modifie JAMAIS l'état lui-même ([t.push(...)]) : [React] compare l'ancienne et la nouvelle valeur ; si c'est le même tableau, il conclut que rien n'a changé et n'affiche rien ;
- lignes 57-58 : on vide le champ et on lui redonne le focus, directement sur l'élément HTML ;
- lignes 62-66 : [evenement.key] donne le nom de la touche. [currentTarget] est l'élément qui porte le gestionnaire (ici, le champ) ;
- ligne 69 : modifier un élément d'un tableau : un nouveau tableau, où l'élément concerné est remplacé par une copie modifiée ([{ ...x, faite }]) ;
- ligne 74 : supprimer un élément : un nouveau tableau, sans lui ;
- lignes 79-81 : l'événement de [React] est un événement « synthétique », qui a la même interface que celui du navigateur ; [nativeEvent] donne l'événement d'origine, ici avec les coordonnées du clic relatives à la zone ([offsetX]).
Le JSX :
- ligne 87 : le gestionnaire du formulaire ;
- ligne 88 : la référence, et un gestionnaire des touches. Ce champ n'est pas contrôlé (pas de [value]) : le navigateur gère sa valeur, qu'on lit quand on en a besoin ;
- lignes 96-97 : un gestionnaire écrit sur place : une fonction fléchée, qui appelle [basculer] avec la tâche de CETTE ligne ;
- ligne 100 : même principe : [onClick={supprimer(t)}] appellerait [supprimer] tout de suite, pendant l'affichage ;
- ligne 113 : la clé des lignes du journal.
On ajoute une tâche, on coche la seconde, on supprime la première, puis on clique dans la zone :

2.5. Exemple 05 : les champs contrôlés
Un formulaire avec tous les types de champs, reliés à l'état :
| // =========================================================================
// 05-champs-controles/src/App.tsx - tous les types de champs, "contrôlés"
// -------------------------------------------------------------------------
// Un champ CONTRÔLÉ affiche une valeur de l'état (value={...}) et prévient
// le composant de chaque changement (onChange={...}), qui met l'état à
// jour. L'état est la seule source de vérité : taper dans le champ modifie
// l'état, et modifier l'état (par le code) modifie le champ.
// C'est la liaison "dans les deux sens", écrite à la main. Ce qu'on lit
// dans l'événement dépend du champ :
// - input texte, textarea -> e.target.value (string)
// - input type="number" -> e.target.valueAsNumber (NaN si vide)
// - case à cocher -> e.target.checked (boolean)
// - boutons radio -> e.target.value du bouton choisi
// - select -> e.target.value
// - select multiple -> e.target.selectedOptions (plusieurs)
// - input type="date" -> e.target.value, "yyyy-MM-dd"
// Même formulaire que l'exemple 18 du cours NestJS ; mais ici, pas de
// serveur : tout se passe dans le navigateur.
// =========================================================================
import { useState, type FormEvent } from 'react';
const TITRES = ['Mr', 'Mme', 'Melle'];
const LOISIRS = ['sport', 'musique', 'lecture', 'cinéma'];
const PAYS = [
{ code: 'fr', libelle: 'France' },
{ code: 'be', libelle: 'Belgique' },
{ code: 'ch', libelle: 'Suisse' },
];
export default function App() {
// un état par champ du formulaire
const [titre, setTitre] = useState('Mme');
const [nom, setNom] = useState('');
const [age, setAge] = useState<number | null>(30);
const [presentation, setPresentation] = useState('');
const [newsletter, setNewsletter] = useState(false);
const [loisirs, setLoisirs] = useState<string[]>(['lecture']);
const [pays, setPays] = useState('fr');
const [langues, setLangues] = useState<string[]>([]);
const [naissance, setNaissance] = useState('');
const [envoye, setEnvoye] = useState<string | null>(null);
// toutes les valeurs, réunies en un objet (ce qu'on enverrait à un serveur)
const modele = { titre, nom: nom.trim(), age, presentation, newsletter, loisirs, pays, langues, naissance };
// plusieurs cases pour UN tableau : on ajoute ou on retire la valeur
function basculerLoisir(loisir: string, coche: boolean) {
setLoisirs((l) => (coche ? [...l, loisir] : l.filter((x) => x !== loisir)));
}
function envoyer(evenement: FormEvent<HTMLFormElement>) {
evenement.preventDefault(); // pas d'envoi au serveur, pas de rechargement
setEnvoye(JSON.stringify(modele));
}
return (
<div className="container py-4">
<h1 className="h3 mb-3">Exemple 05 - les champs contrôlés</h1>
<div className="row g-4">
<form className="col-md-7" onSubmit={envoyer}>
<div className="mb-3">
<span className="form-label d-block">Titre</span>
{/* boutons radio : un même état ; checked dit lequel est coché */}
{TITRES.map((t) => (
<div key={t} className="form-check form-check-inline">
<input type="radio" className="form-check-input" name="titre" id={`titre-${t}`} value={t}
checked={titre === t} onChange={(e) => setTitre(e.target.value)} />
<label className="form-check-label" htmlFor={`titre-${t}`}>{t}</label>
</div>
))}
</div>
<div className="row g-3 mb-3">
<div className="col-8">
<label className="form-label" htmlFor="nom">Nom</label>
<input id="nom" className="form-control" value={nom} onChange={(e) => setNom(e.target.value)} />
</div>
<div className="col-4">
<label className="form-label" htmlFor="age">Âge</label>
{/* un champ vide donne NaN : on range null dans l'état, '' dans le champ */}
<input id="age" type="number" className="form-control" value={age ?? ''}
onChange={(e) => setAge(Number.isNaN(e.target.valueAsNumber) ? null : e.target.valueAsNumber)} />
</div>
</div>
<div className="mb-3">
<label className="form-label" htmlFor="presentation">Présentation (mise à jour à la sortie du champ)</label>
{/* un champ NON contrôlé : defaultValue est sa valeur de départ, puis
le navigateur le gère seul ; onBlur ne prévient qu'à la sortie */}
<textarea id="presentation" rows={2} className="form-control" defaultValue={presentation}
onBlur={(e) => setPresentation(e.target.value)} />
</div>
<div className="mb-3 form-check">
{/* une case seule : true / false */}
<input id="newsletter" type="checkbox" className="form-check-input" checked={newsletter}
onChange={(e) => setNewsletter(e.target.checked)} />
<label htmlFor="newsletter" className="form-check-label">Recevoir la lettre d'information</label>
</div>
<div className="mb-3">
<span className="form-label d-block">Loisirs</span>
{LOISIRS.map((l) => (
<div key={l} className="form-check form-check-inline">
<input type="checkbox" className="form-check-input" id={`loisir-${l}`} checked={loisirs.includes(l)}
onChange={(e) => basculerLoisir(l, e.target.checked)} />
<label className="form-check-label" htmlFor={`loisir-${l}`}>{l}</label>
</div>
))}
</div>
<div className="row g-3 mb-3">
<div className="col-6">
<label className="form-label" htmlFor="pays">Pays</label>
{/* value sur le <select> (et non selected sur les <option>) */}
<select id="pays" className="form-select" value={pays} onChange={(e) => setPays(e.target.value)}>
{PAYS.map((p) => (
<option key={p.code} value={p.code}>{p.libelle}</option>
))}
</select>
</div>
<div className="col-6">
<label className="form-label" htmlFor="langues">Langues parlées</label>
{/* multiple : value est un TABLEAU ; selectedOptions, les options choisies */}
<select id="langues" multiple size={3} className="form-select" value={langues}
onChange={(e) => setLangues([...e.target.selectedOptions].map((o) => o.value))}>
<option value="fr">français</option>
<option value="en">anglais</option>
<option value="es">espagnol</option>
</select>
</div>
</div>
<div className="mb-3">
<label className="form-label" htmlFor="naissance">Date de naissance</label>
<input id="naissance" type="date" className="form-control w-auto" value={naissance}
onChange={(e) => setNaissance(e.target.value)} />
</div>
<button type="submit" className="btn btn-primary">Envoyer</button>
</form>
<div className="col-md-5">
<h2 className="h6">Le modèle, en direct</h2>
{/* JSON.stringify(objet, null, 2) : le JSON, indenté de 2 espaces */}
<pre className="bg-light p-2 rounded small">{JSON.stringify(modele, null, 2)}</pre>
{envoye !== null && (
<>
<h2 className="h6">Dernier envoi</h2>
<pre className="bg-success-subtle p-2 rounded small text-wrap">{envoye}</pre>
</>
)}
</div>
</div>
</div>
);
}
|
Le principe (lignes 4-6) : un champ contrôlé affiche une valeur de l'état ([value={...}]) et prévient le composant de chaque changement ([onChange={...}]), qui met l'état à jour. L'état est la seule source de vérité : le champ ne fait qu'afficher ce que dit l'état. Si le gestionnaire [onChange] ne changeait pas l'état, le champ refuserait les frappes.
- lignes 33-41 : un état par champ ;
- ligne 45 : toutes les valeurs, réunies en un objet : ce qu'on enverrait à un serveur. C'est une valeur calculée ; [nom.trim()] retire les espaces du début et de la fin ;
- lignes 48-50 : plusieurs cases à cocher pour UN tableau : on ajoute ou on retire la valeur ;
- lignes 52-55 : l'envoi du formulaire, sans rechargement de la page.
Le JSX, champ par champ :
- lignes 65-68 : des boutons radio : ils partagent le même état ; [checked] dit lequel est coché. [key={t}] : la clé de chaque bouton ;
- ligne 76 : un champ texte ;
- lignes 81-82 : un champ numérique : un champ vide donne [NaN] ; on range alors [null] dans l'état, et l'on affiche [''] ([age ?? ''] : [''] si [age] vaut [null] ou [undefined]) ;
- lignes 89-90 : un champ non contrôlé : [defaultValue] est sa valeur de départ, puis le navigateur le gère seul ; [onBlur] prévient le composant quand on quitte le champ. L'état n'est donc mis à jour qu'à la sortie du champ ;
- ligne 94 : une case seule : [checked] et [e.target.checked] ;
- lignes 100-103 : les cases des loisirs ;
- ligne 112 : une liste : [value] sur le [<select>], et non [selected] sur les [<option>] ;
- lignes 121-122 : une liste à choix multiples : [value] est un tableau ; [e.target.selectedOptions] donne les options choisies, que l'on transforme en tableau de valeurs ([[...liste]] transforme une collection en tableau) ;
- ligne 131 : une date, sous forme de chaîne [yyyy-MM-dd] ;
- ligne 140 : le modèle, au format JSON, indenté de 2 espaces.

Écrire [value] et [onChange] sur chaque champ est répétitif : c'est le prix de la simplicité de [React], qui n'a pas d'équivalent au [v-model] de [Vue.js] ou au [[(ngModel)]] d'[Angular]. Les exemples suivants montrent comment réduire cette répétition : un objet d'état unique (exemple 06), une fonction qui fabrique les attributs d'un champ (exemples 06 et 07), une bibliothèque (exemple 08).
| // =========================================================================
// 06-use-reducer/src/App.tsx - un formulaire, un objet, un réducteur
// -------------------------------------------------------------------------
// L'exemple 05 avait un useState par champ. Pour un vrai formulaire, on
// préfère UN objet qui contient toutes les valeurs, le MODÈLE, et l'on
// décrit ses changements dans une seule fonction, le RÉDUCTEUR :
//
// reducteur(etatActuel, action) -> nouvelEtat
//
// useReducer(reducteur, etatInitial) renvoie l'état et une fonction
// dispatch(action) : le composant ne modifie plus l'état, il DÉCRIT ce qui
// s'est passé ({ type: 'modifier', champ: 'nom', valeur: 'Dupont' }) et le
// réducteur en déduit le nouvel état. Toute la logique est en un endroit,
// et c'est une fonction PURE (même entrée, même sortie, aucun effet de
// bord), facile à tester.
//
// L'état garde aussi, pour chaque champ, s'il a été "touché" (quitté au
// moins une fois) ; on en déduit s'il est "modifié" (sa valeur diffère de
// la valeur de départ) et s'il est désactivé.
// =========================================================================
import { useReducer, type ChangeEvent } from 'react';
// la forme des données du formulaire
interface Profil {
titre: string;
prenom: string;
nom: string;
age: number;
newsletter: boolean;
email: string;
pays: string;
naissance: string;
presentation: string;
}
const DEPART: Profil = {
titre: 'Mme',
prenom: 'Marie',
nom: 'PELISSIER',
age: 30,
newsletter: false,
email: '',
pays: 'fr',
naissance: '',
presentation: '',
};
type Champ = keyof Profil; // 'titre' | 'prenom' | ... : le nom d'un champ
interface Etat {
valeurs: Profil;
touches: Partial<Record<Champ, boolean>>; // les champs déjà quittés
}
// les actions possibles : une "union discriminée" par la propriété type
type Action =
| { type: 'modifier'; champ: Champ; valeur: Profil[Champ] }
| { type: 'quitter'; champ: Champ }
| { type: 'majuscules' }
| { type: 'reinitialiser' };
// le réducteur : il ne MODIFIE jamais l'état reçu, il en renvoie un nouveau
function reducteur(etat: Etat, action: Action): Etat {
switch (action.type) {
case 'modifier':
return { ...etat, valeurs: { ...etat.valeurs, [action.champ]: action.valeur } };
case 'quitter':
return { ...etat, touches: { ...etat.touches, [action.champ]: true } };
case 'majuscules': {
const { prenom, nom } = etat.valeurs;
return { ...etat, valeurs: { ...etat.valeurs, prenom: prenom.toUpperCase(), nom: nom.toUpperCase() } };
}
case 'reinitialiser':
return { valeurs: { ...DEPART }, touches: {} };
}
}
const TITRES = ['Mr', 'Mme', 'Melle'];
const CHAMPS_SUIVIS: Champ[] = ['prenom', 'nom', 'age', 'email'];
export default function App() {
const [etat, dispatch] = useReducer(reducteur, { valeurs: { ...DEPART }, touches: {} });
const v = etat.valeurs;
// des valeurs DÉDUITES de l'état
const modifie = (c: Champ) => v[c] !== DEPART[c];
const desactive = (c: Champ) => c === 'email' && !v.newsletter; // l'e-mail exige la lettre d'information
const formulaireModifie = (Object.keys(DEPART) as Champ[]).some(modifie);
// les attributs communs à un champ texte : une fonction qui les fabrique
// (id, value, onChange, onBlur, disabled), à "étaler" sur l'élément : {...lier('nom')}
function lier(champ: 'prenom' | 'nom' | 'email' | 'naissance' | 'presentation' | 'pays') {
return {
id: champ,
value: v[champ],
disabled: desactive(champ),
onChange: (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) =>
dispatch({ type: 'modifier', champ, valeur: e.target.value }),
onBlur: () => dispatch({ type: 'quitter', champ }),
};
}
return (
<div className="container py-4">
<h1 className="h3 mb-3">Exemple 06 - un formulaire, un objet, un réducteur</h1>
<div className="row g-4">
<form className="col-md-7" onSubmit={(e) => e.preventDefault()}>
<div className="mb-3">
<span className="form-label d-block">Titre</span>
{TITRES.map((t) => (
<div key={t} className="form-check form-check-inline">
<input type="radio" className="form-check-input" id={`titre-${t}`} name="titre" value={t}
checked={v.titre === t} onChange={() => dispatch({ type: 'modifier', champ: 'titre', valeur: t })} />
<label className="form-check-label" htmlFor={`titre-${t}`}>{t}</label>
</div>
))}
</div>
<div className="row g-3 mb-3">
<div className="col-5">
<label className="form-label" htmlFor="prenom">Prénom</label>
{/* {...objet} : chaque propriété de l'objet devient un attribut */}
<input className="form-control" {...lier('prenom')} />
</div>
<div className="col-5">
<label className="form-label" htmlFor="nom">Nom</label>
<input className="form-control" {...lier('nom')} />
</div>
<div className="col-2">
<label className="form-label" htmlFor="age">Âge</label>
<input id="age" type="number" className="form-control" value={v.age}
onChange={(e) => dispatch({ type: 'modifier', champ: 'age', valeur: e.target.valueAsNumber || 0 })}
onBlur={() => dispatch({ type: 'quitter', champ: 'age' })} />
</div>
</div>
<div className="row g-3 mb-3 align-items-end">
<div className="col-5">
<div className="form-check">
<input id="newsletter" type="checkbox" className="form-check-input" checked={v.newsletter}
onChange={(e) => dispatch({ type: 'modifier', champ: 'newsletter', valeur: e.target.checked })} />
<label htmlFor="newsletter" className="form-check-label">Recevoir la lettre d'information</label>
</div>
</div>
<div className="col-7">
<label className="form-label" htmlFor="email">E-mail (si lettre d'information)</label>
<input type="email" className="form-control" {...lier('email')} />
</div>
</div>
<div className="row g-3 mb-3">
<div className="col-6">
<label className="form-label" htmlFor="pays">Pays</label>
<select className="form-select" {...lier('pays')}>
<option value="fr">France</option>
<option value="be">Belgique</option>
<option value="ch">Suisse</option>
</select>
</div>
<div className="col-6">
<label className="form-label" htmlFor="naissance">Date de naissance</label>
<input type="date" className="form-control" {...lier('naissance')} />
</div>
</div>
<div className="mb-3">
<label className="form-label" htmlFor="presentation">Présentation</label>
<textarea rows={2} className="form-control" {...lier('presentation')} />
</div>
<div className="d-flex gap-2">
<button type="button" className="btn btn-outline-primary" onClick={() => dispatch({ type: 'majuscules' })}>
Nom en majuscules
</button>
<button type="button" className="btn btn-outline-secondary" onClick={() => dispatch({ type: 'reinitialiser' })}>
Réinitialiser
</button>
</div>
</form>
<div className="col-md-5">
<h2 className="h6">Le modèle, en direct</h2>
<pre className="bg-light p-2 rounded small">{JSON.stringify(v, null, 2)}</pre>
<h2 className="h6">L'état des champs</h2>
<table className="table table-sm small">
<thead><tr><th>champ</th><th>touché</th><th>modifié</th><th>désactivé</th></tr></thead>
<tbody>
{CHAMPS_SUIVIS.map((c) => (
<tr key={c}>
<td>{c}</td>
<td>{String(etat.touches[c] ?? false)}</td>
<td>{String(modifie(c))}</td>
<td>{String(desactive(c))}</td>
</tr>
))}
</tbody>
</table>
<p className="small mb-0">formulaire modifié : <strong>{String(formulaireModifie)}</strong></p>
</div>
</div>
</div>
);
}
|
- lignes 25-35 : la forme des données du formulaire ; [DEPART] (lignes 37-47), leurs valeurs de départ ;
- ligne 49 : [keyof Profil] est le type des noms de propriétés de [Profil] : ['titre' | 'prenom' | ...] ;
- lignes 51-54 : l'état complet : les valeurs, et les champs déjà « touchés » (quittés au moins une fois). [Partial<Record<Champ, boolean>>] : un objet dont les clés sont des noms de champs, toutes facultatives ;
- lignes 57-61 : les actions possibles, sous la forme d'une « union discriminée » : chaque action a une propriété [type], qui dit de quelle action il s'agit, et ses propres données ;
- lignes 64-77 : le réducteur : une fonction qui reçoit l'état actuel et une action, et renvoie le NOUVEL état. Elle ne modifie jamais l'état reçu (elle en fabrique une copie modifiée, [{ ...etat, ... }]) et ne fait rien d'autre : c'est une fonction pure, facile à comprendre et à tester. [[action.champ]: action.valeur] : une propriété dont le nom est calculé ;
- ligne 83 : [useReducer(reducteur, étatInitial)] renvoie l'état et une fonction [dispatch(action)] : le composant ne modifie plus l'état, il décrit ce qui s'est passé ([dispatch({ type: 'quitter', champ: 'nom' })]), et [React] appelle le réducteur pour en déduire le nouvel état ;
- lignes 87-89 : des valeurs déduites de l'état : un champ est « modifié » si sa valeur diffère de la valeur de départ ; l'e-mail est désactivé tant que la case « lettre d'information » n'est pas cochée ;
- lignes 93-102 : une fonction qui fabrique les attributs communs à un champ : [id], [value], [disabled], [onChange], [onBlur]. On les « étale » sur l'élément avec la syntaxe [{...objet}] (ligne 123) : chaque propriété de l'objet devient un attribut. Une seule ligne suffit alors à relier un champ au formulaire.
Dans le JSX, les champs particuliers (boutons radio, âge, case à cocher) appellent [dispatch] directement ; les boutons « Nom en majuscules » et « Réinitialiser » envoient les actions correspondantes. À droite, le modèle en direct, et l'état des champs.
On tape un prénom, on passe par le nom, on coche la lettre d'information, on tape un e-mail, puis on clique sur « Nom en majuscules » :

[useState] convient à des données indépendantes ; [useReducer] à un état composé, dont les changements suivent des règles : toute la logique est en un seul endroit, hors du composant. C'est la même idée que les « magasins » de [Redux] ou de [Vuex], à l'échelle d'un composant.
| // =========================================================================
// 07-validation/src/App.tsx - valider un formulaire dans le navigateur
// -------------------------------------------------------------------------
// React n'a pas de système de validation : c'est du code ordinaire. Une
// fonction PURE, valider(compte), renvoie les erreurs des champs ; on
// l'appelle à chaque affichage, les erreurs sont donc toujours à jour.
// On ne les montre pas d'emblée (un formulaire vide couvert de rouge est
// désagréable) : seulement pour un champ déjà quitté ("touché"). L'envoi
// marque tous les champs comme touchés, puis n'agit que si le formulaire
// est valide.
//
// Mêmes règles que le serveur RdvMedecins (formulaire "Créer un compte").
// ATTENTION : cette validation n'est qu'un CONFORT pour l'utilisateur (une
// réponse immédiate, sans aller-retour avec le serveur). Elle ne protège
// rien : n'importe qui peut envoyer au serveur une requête fabriquée à la
// main (curl...). Le serveur revalide donc TOUJOURS tout (cf. exemple 25).
// =========================================================================
import { useState, type FormEvent } from 'react';
interface Compte {
titre: string;
prenom: string;
nom: string;
login: string;
password: string;
confirmation: string;
}
type Champ = keyof Compte;
// au plus un message d'erreur par champ
type Erreurs = Partial<Record<Champ, string>>;
const TITRES = ['Mr', 'Mme', 'Melle'];
const FORMAT_LOGIN = /^[a-z0-9._-]{1,30}$/;
const MOT_DE_PASSE_MIN = 6;
// les règles : une fonction pure, qui ne dépend que de ses arguments
function valider(c: Compte): Erreurs {
const erreurs: Erreurs = {};
if (!TITRES.includes(c.titre)) erreurs.titre = 'Choisissez un titre dans la liste.';
if (c.prenom.trim() === '') erreurs.prenom = 'Le prénom est obligatoire.';
else if (c.prenom.length > 20) erreurs.prenom = 'Le prénom ne doit pas dépasser 20 caractères.';
if (c.nom.trim() === '') erreurs.nom = 'Le nom est obligatoire.';
else if (c.nom.length > 20) erreurs.nom = 'Le nom ne doit pas dépasser 20 caractères.';
if (c.login === '') erreurs.login = 'Le login est obligatoire.';
else if (!FORMAT_LOGIN.test(c.login)) erreurs.login = 'Lettres minuscules non accentuées, chiffres, point, tiret, souligné.';
if (c.password === '') erreurs.password = 'Le mot de passe est obligatoire.';
else if (c.password.length < MOT_DE_PASSE_MIN) erreurs.password = `Au moins ${MOT_DE_PASSE_MIN} caractères.`;
// une règle qui compare DEUX champs
if (c.confirmation !== c.password) erreurs.confirmation = 'La confirmation ne correspond pas au mot de passe.';
return erreurs;
}
const VIDE: Compte = { titre: 'Mr', prenom: '', nom: '', login: '', password: '', confirmation: '' };
const TOUS: Record<Champ, boolean> = { titre: true, prenom: true, nom: true, login: true, password: true, confirmation: true };
export default function App() {
const [compte, setCompte] = useState<Compte>(VIDE);
const [touches, setTouches] = useState<Partial<Record<Champ, boolean>>>({});
const [envoiTente, setEnvoiTente] = useState(false);
const [succes, setSucces] = useState(false);
// les erreurs : recalculées à chaque affichage, jamais rangées dans l'état
const erreurs = valider(compte);
const nbErreurs = Object.keys(erreurs).length;
// la classe Bootstrap d'un champ : rouge (is-invalid) ou vert (is-valid),
// seulement une fois qu'il a été touché
function classe(champ: Champ, base = 'form-control'): string {
if (!touches[champ]) return base;
return `${base} ${erreurs[champ] ? 'is-invalid' : 'is-valid'}`;
}
// les attributs communs à tous les champs
function lier(champ: Champ) {
return {
id: champ,
value: compte[champ],
onChange: (e: { target: { value: string } }) => {
setCompte((c) => ({ ...c, [champ]: e.target.value }));
setSucces(false);
},
onBlur: () => setTouches((t) => ({ ...t, [champ]: true })),
};
}
function envoyer(evenement: FormEvent<HTMLFormElement>) {
evenement.preventDefault();
setEnvoiTente(true);
setTouches(TOUS); // toutes les erreurs apparaissent
// ici, on enverrait le compte au serveur (cf. exemple 25)
setSucces(nbErreurs === 0);
}
return (
<div className="container py-4" style={{ maxWidth: 640 }}>
<h1 className="h3 mb-3">Exemple 07 - la validation d'un formulaire</h1>
{/* noValidate : on désactive la validation propre au navigateur, pour
afficher NOS messages, dans NOTRE style */}
<form className="card p-4" noValidate onSubmit={envoyer}>
<div className="mb-3">
<label className="form-label" htmlFor="titre">Titre</label>
<select className={classe('titre', 'form-select')} {...lier('titre')}>
{TITRES.map((t) => <option key={t}>{t}</option>)}
</select>
<div className="invalid-feedback">{erreurs.titre}</div>
</div>
<div className="row g-3 mb-3">
<div className="col-sm-6">
<label className="form-label" htmlFor="prenom">Prénom</label>
{/* is-invalid encadre le champ de rouge ET fait apparaître le
.invalid-feedback qui le suit : on y écrit l'erreur du champ */}
<input className={classe('prenom')} {...lier('prenom')} />
<div className="invalid-feedback">{erreurs.prenom}</div>
</div>
<div className="col-sm-6">
<label className="form-label" htmlFor="nom">Nom</label>
<input className={classe('nom')} {...lier('nom')} />
<div className="invalid-feedback">{erreurs.nom}</div>
</div>
</div>
<div className="mb-3">
<label className="form-label" htmlFor="login">Login</label>
<input className={classe('login')} autoComplete="username" {...lier('login')} />
<div className="invalid-feedback">{erreurs.login}</div>
</div>
<div className="row g-3 mb-4">
<div className="col-sm-6">
<label className="form-label" htmlFor="password">Mot de passe</label>
<input type="password" className={classe('password')} autoComplete="new-password" {...lier('password')} />
<div className="invalid-feedback">{erreurs.password}</div>
</div>
<div className="col-sm-6">
<label className="form-label" htmlFor="confirmation">Confirmation</label>
<input type="password" className={classe('confirmation')} autoComplete="new-password" {...lier('confirmation')} />
<div className="invalid-feedback">{erreurs.confirmation}</div>
</div>
</div>
<button type="submit" className="btn btn-primary">Créer le compte</button>
{envoiTente && nbErreurs > 0 && (
<div className="alert alert-danger mt-3 mb-0">Le formulaire contient {nbErreurs} erreur(s).</div>
)}
{succes && <div className="alert alert-success mt-3 mb-0">Formulaire valide : il pourrait être envoyé au serveur.</div>}
</form>
</div>
);
}
|
[React] n'a pas de système de validation : c'est du code ordinaire.
- lignes 30-32 : au plus un message d'erreur par champ ;
- lignes 39-53 : les règles : une fonction pure, qui renvoie les erreurs des champs ; ce sont les mêmes règles que celles du serveur [RdvMedecins] pour la création d'un compte. La dernière règle compare deux champs ;
- ligne 65 : les erreurs sont recalculées à chaque affichage, jamais rangées dans l'état : elles sont donc toujours à jour ;
- lignes 70-73 : la classe [Bootstrap] d'un champ : [is-invalid] (cadre rouge) ou [is-valid] (cadre vert), seulement une fois le champ touché : un formulaire vide couvert de rouge serait désagréable ;
- lignes 76-86 : comme à l'exemple 06, les attributs communs des champs ; quitter un champ le marque comme touché ;
- lignes 88-94 : l'envoi marque TOUS les champs comme touchés (leurs erreurs apparaissent), puis ne réussit que si le formulaire est valide.
Le JSX :
- ligne 101 : on désactive la validation propre au navigateur, pour afficher NOS messages, dans NOTRE style ;
- lignes 114-115 : la classe [is-invalid] encadre le champ de rouge ET fait apparaître le [.invalid-feedback] qui le suit, où l'on écrit l'erreur du champ.
On tape un prénom, on passe par le nom, on tape un login invalide :

On clique sur « Créer le compte » :

Une fois tout corrigé :

ATTENTION : cette validation n'est qu'un confort pour l'utilisateur (une réponse immédiate, sans aller-retour avec le serveur). Elle ne protège rien : n'importe qui peut envoyer au serveur une requête fabriquée à la main ([curl]...). Le serveur revalide donc TOUJOURS tout (cf. exemple 25).
Le formulaire de l'exemple 07, écrit avec [React Hook Form], la bibliothèque de formulaires la plus utilisée avec [React] :
| // =========================================================================
// 08-react-hook-form/src/App.tsx - une bibliothèque de formulaires
// -------------------------------------------------------------------------
// Le formulaire de l'exemple 07, écrit avec React Hook Form, la bibliothèque
// de formulaires la plus utilisée avec React. On la rencontre dans la
// plupart des applications existantes.
// - useForm<T>() renvoie des outils pour un formulaire de type T ;
// - register('nom', règles) relie un champ HTML au champ "nom" du
// formulaire : il renvoie les attributs à étaler sur l'élément (name,
// ref, onChange, onBlur). Les champs NE sont PAS contrôlés : c'est la
// bibliothèque qui lit leur valeur dans la page, sans rappeler le
// composant à chaque frappe ;
// - les règles : required, maxLength, minLength, pattern, validate (une
// règle "maison"), chacune avec son message ;
// - formState.errors : les erreurs de chaque champ ;
// - handleSubmit(action) : valide tout ; n'appelle l'action que si le
// formulaire est valide.
// =========================================================================
import { useState } from 'react';
import { useForm } from 'react-hook-form';
interface Compte {
titre: string;
prenom: string;
nom: string;
login: string;
password: string;
confirmation: string;
}
const TITRES = ['Mr', 'Mme', 'Melle'];
export default function App() {
const {
register,
handleSubmit,
getValues,
formState: { errors, isSubmitted },
} = useForm<Compte>({
// les valeurs de départ
defaultValues: { titre: 'Mr', prenom: '', nom: '', login: '', password: '', confirmation: '' },
// quand valider : à la sortie d'un champ, puis à chaque frappe
mode: 'onTouched',
});
const [envoi, setEnvoi] = useState('');
// un champ est signalé en erreur s'il en a une (errors.nom existe)
const classe = (champ: keyof Compte) => `form-control${errors[champ] ? ' is-invalid' : ''}`;
// appelée par handleSubmit, SEULEMENT si le formulaire est valide ;
// elle reçoit les valeurs, typées
function creer(compte: Compte) {
setEnvoi(JSON.stringify(compte));
}
return (
<div className="container py-4" style={{ maxWidth: 640 }}>
<h1 className="h3 mb-3">Exemple 08 - React Hook Form</h1>
<form className="card p-4" noValidate onSubmit={handleSubmit(creer, () => setEnvoi(''))}>
<div className="mb-3">
<label className="form-label" htmlFor="titre">Titre</label>
<select id="titre" className="form-select" {...register('titre', { required: true })}>
{TITRES.map((t) => <option key={t}>{t}</option>)}
</select>
</div>
<div className="row g-3 mb-3">
<div className="col-sm-6">
<label className="form-label" htmlFor="prenom">Prénom</label>
<input id="prenom" className={classe('prenom')}
{...register('prenom', {
required: 'Le prénom est obligatoire.',
maxLength: { value: 20, message: '20 caractères au plus.' },
})} />
{/* errors.prenom?.message : le message de la règle en échec */}
<div className="invalid-feedback">{errors.prenom?.message}</div>
</div>
<div className="col-sm-6">
<label className="form-label" htmlFor="nom">Nom</label>
<input id="nom" className={classe('nom')}
{...register('nom', {
required: 'Le nom est obligatoire.',
maxLength: { value: 20, message: '20 caractères au plus.' },
})} />
<div className="invalid-feedback">{errors.nom?.message}</div>
</div>
</div>
<div className="mb-3">
<label className="form-label" htmlFor="login">Login</label>
<input id="login" className={classe('login')}
{...register('login', {
required: 'Le login est obligatoire.',
pattern: { value: /^[a-z0-9._-]{1,30}$/, message: 'Lettres minuscules non accentuées, chiffres, point, tiret, souligné.' },
})} />
<div className="invalid-feedback">{errors.login?.message}</div>
</div>
<div className="row g-3 mb-4">
<div className="col-sm-6">
<label className="form-label" htmlFor="password">Mot de passe</label>
<input id="password" type="password" className={classe('password')}
{...register('password', {
required: 'Le mot de passe est obligatoire.',
minLength: { value: 6, message: 'Au moins 6 caractères.' },
// deps : quand le mot de passe change, revalider AUSSI la confirmation
deps: ['confirmation'],
})} />
<div className="invalid-feedback">{errors.password?.message}</div>
</div>
<div className="col-sm-6">
<label className="form-label" htmlFor="confirmation">Confirmation</label>
{/* validate : une règle "maison" ; getValues lit un AUTRE champ */}
<input id="confirmation" type="password" className={classe('confirmation')}
{...register('confirmation', {
validate: (valeur) => valeur === getValues('password') || 'La confirmation ne correspond pas au mot de passe.',
})} />
<div className="invalid-feedback">{errors.confirmation?.message}</div>
</div>
</div>
<button type="submit" className="btn btn-primary">Créer le compte</button>
{isSubmitted && (
<div className={`alert mt-3 mb-0 ${envoi ? 'alert-success' : 'alert-danger'}`}>
{envoi ? <>Formulaire valide : <code>{envoi}</code></> : 'Formulaire invalide.'}
</div>
)}
</form>
</div>
);
}
|
- ligne 21 : le hook principal de la bibliothèque ;
- lignes 35-40 : [useForm<Compte>(options)] renvoie les outils d'un formulaire de type [Compte], qu'on déstructure : [register], [handleSubmit], [getValues], et [formState], l'état du formulaire (dont [errors], les erreurs des champs, et [isSubmitted], vrai après un premier envoi) ;
- ligne 42 : les valeurs de départ ;
- ligne 44 : quand valider : à la sortie d'un champ, puis à chaque frappe ;
- ligne 49 : un champ est signalé en erreur s'il en a une ;
- lignes 53-55 : l'action d'envoi, appelée par [handleSubmit] seulement si le formulaire est valide ; elle reçoit les valeurs, typées ;
- ligne 60 : [handleSubmit(siValide, siInvalide)] fabrique le gestionnaire de l'envoi : il valide tous les champs, puis appelle l'une ou l'autre fonction ;
- lignes 71-74 : [register('prenom', règles)] relie le champ HTML au champ [prenom] du formulaire, avec ses règles : [required], [maxLength], [minLength], [pattern], et [validate] (une règle « maison »), chacune avec son message. [register] renvoie les attributs à étaler sur l'élément ([name], [ref], [onChange], [onBlur]). Les champs ne sont PAS contrôlés : la bibliothèque lit leur valeur dans la page, par la référence ; le composant n'est donc pas rappelé à chaque frappe, ce qui compte pour les grands formulaires ;
- ligne 76 : le message de la règle en échec ([?.] : [undefined] si le champ n'a pas d'erreur) ;
- ligne 105 : quand le mot de passe change, revalider aussi la confirmation ;
- ligne 114 : une règle maison, qui compare deux champs : [getValues('password')] lit la valeur d'un autre champ. Elle renvoie [true] (valide) ou le message d'erreur.

Autour de [React], chaque besoin a plusieurs bibliothèques concurrentes. Pour les formulaires, on rencontre aussi [Formik], [TanStack Form], et, pour décrire les règles, [Zod] ou [Yup] (des « schémas » de validation, qu'on peut partager avec un serveur [Node.js]). Pour l'étude de cas, où toute la validation est faite par le serveur, nous n'utiliserons pas de bibliothèque : des champs contrôlés et un hook maison suffiront.
[Angular] a ses « pipes » pour mettre en forme une valeur dans un template. [React] n'a pas de mécanisme propre : dans du JSX, on appelle des fonctions, rangées dans un module. Pour les dates, les nombres et les montants, le navigateur fournit l'API [Intl], qui connaît les conventions de chaque langue.
| // =========================================================================
// 09-mise-en-forme/src/formatage.ts - des fonctions de mise en forme "maison"
// -------------------------------------------------------------------------
// React n'a pas de mécanisme propre à la mise en forme (Angular a ses
// "pipes", Vue.js ses fonctions) : dans du JSX, on appelle des FONCTIONS
// ordinaires, rangées dans un module et testables à part :
// {horaire(creneau)} -> "8:00 - 8:20"
// {horaire(creneau, 'debut')} -> "8:00"
// Pour les dates, les nombres, les montants, le navigateur fournit l'API
// Intl, qui connaît les conventions de chaque langue.
// =========================================================================
export interface Creneau {
hdebut: number;
mdebut: number;
hfin: number;
mfin: number;
}
// "8:05" plutôt que "8:5"
function heure(h: number, m: number): string {
return `${h}:${String(m).padStart(2, '0')}`;
}
export function horaire(c: Creneau, partie: 'tout' | 'debut' | 'fin' = 'tout'): string {
switch (partie) {
case 'debut':
return heure(c.hdebut, c.mdebut);
case 'fin':
return heure(c.hfin, c.mfin);
default:
return `${heure(c.hdebut, c.mdebut)} - ${heure(c.hfin, c.mfin)}`;
}
}
// "marie pelissier" -> "Marie Pelissier"
export function majusculeInitiale(texte: string): string {
return texte.replace(/\p{L}+/gu, (mot) => mot[0].toUpperCase() + mot.slice(1).toLowerCase());
}
// "Marie PELISSIER" -> "MP"
export function initiales(nomComplet: string): string {
return nomComplet
.split(/\s+/)
.filter((mot) => mot !== '')
.map((mot) => mot[0].toUpperCase())
.join('');
}
// "2026-10-05" (le format de l'API RdvMedecins) -> une Date, à minuit UTC
export function versDate(jour: string): Date {
return new Date(`${jour}T00:00:00Z`);
}
|
- lignes 13-18 : un créneau horaire, tel que l'envoie le serveur [RdvMedecins] (heure et minute de début et de fin) ;
- lignes 21-23 : [padStart(2, '0')] complète la chaîne à gauche : « 5 » devient « 05 » ;
- lignes 25-34 : « 8:00 - 8:20 », ou seulement le début, ou la fin, selon le paramètre [partie], facultatif (valeur par défaut ['tout']) ;
- lignes 37-39 : [\p{L}+] désigne une suite de lettres, accentuées comprises (option [u]) : on met la première en majuscule ;
- lignes 51-53 : une date de l'API (["2026-10-05"]) devient un objet [Date], à minuit UTC.
2.9.2. La page
| // =========================================================================
// 09-mise-en-forme/src/App.tsx - mettre en forme des valeurs dans du JSX
// -------------------------------------------------------------------------
// Chaque ligne du tableau montre un morceau de code et ce qu'il affiche.
// Les formateurs Intl sont créés une fois pour toutes, hors du composant :
// les fabriquer coûte plus cher que de s'en servir.
// =========================================================================
import { useState } from 'react';
import { horaire, initiales, majusculeInitiale, versDate, type Creneau } from './formatage';
// dates : dateStyle / timeStyle, ou le détail des parties à afficher
const dateCourte = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium' });
const dateComplete = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'full' });
const heureSeule = new Intl.DateTimeFormat('fr-FR', { timeStyle: 'medium' });
const jourLong = new Intl.DateTimeFormat('fr-FR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' });
const jourAnglais = new Intl.DateTimeFormat('en-US', { dateStyle: 'long', timeZone: 'UTC' });
// nombres et montants
const euros = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' });
const entier = new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 0 });
const pourcent = new Intl.NumberFormat('fr-FR', { style: 'percent', minimumFractionDigits: 1 });
const jour = '2026-10-05'; // une date au format de l'API RdvMedecins
const prix = 1234.5;
const taux = 0.055;
const nom = 'marie pelissier';
const medecin = { titre: 'Mme', prenom: 'Marie', nom: 'PELISSIER' };
const langages = ['TypeScript', 'HTML', 'CSS', 'JavaScript', 'SQL'];
const creneaux: Creneau[] = [
{ hdebut: 8, mdebut: 0, hfin: 8, mfin: 20 },
{ hdebut: 8, mdebut: 20, hfin: 8, mfin: 40 },
{ hdebut: 14, mdebut: 5, hfin: 14, mfin: 25 },
];
export default function App() {
const [maintenant, setMaintenant] = useState(() => new Date());
// [code affiché, valeur] : une ligne du tableau
const lignes: [string, string][] = [
['dateCourte.format(maintenant)', dateCourte.format(maintenant)],
['dateComplete.format(maintenant)', dateComplete.format(maintenant)],
['heureSeule.format(maintenant)', heureSeule.format(maintenant)],
['jourLong.format(versDate(jour))', jourLong.format(versDate(jour))],
['jourAnglais.format(versDate(jour))', jourAnglais.format(versDate(jour))],
['maintenant.toLocaleDateString()', maintenant.toLocaleDateString()],
['euros.format(prix)', euros.format(prix)],
['entier.format(prix)', entier.format(prix)],
['pourcent.format(taux)', pourcent.format(taux)],
['nom.toUpperCase()', nom.toUpperCase()],
['majusculeInitiale(nom)', majusculeInitiale(nom)],
["'RDV'.toLowerCase()", 'RDV'.toLowerCase()],
["langages.slice(0, 3).join(', ')", langages.slice(0, 3).join(', ')],
['JSON.stringify(medecin)', JSON.stringify(medecin)],
// deux fonctions à la suite : le résultat de la première passe à la seconde
['initiales(majusculeInitiale(nom))', initiales(majusculeInitiale(nom))],
];
return (
<div className="container py-4" style={{ maxWidth: 820 }}>
<h1 className="h3 mb-3">Exemple 09 - la mise en forme des valeurs</h1>
<table className="table table-sm align-middle">
<thead><tr><th>Dans le code</th><th>Affiché</th></tr></thead>
<tbody>
{lignes.map(([code, valeur]) => (
<tr key={code}><td><code>{`{${code}}`}</code></td><td>{valeur}</td></tr>
))}
</tbody>
</table>
<h2 className="h6">Object.entries : parcourir les propriétés d'un objet</h2>
<ul>
{Object.entries(medecin).map(([cle, valeur]) => (
<li key={cle}>{cle} = {valeur}</li>
))}
</ul>
<h2 className="h6">Une fonction maison : horaire</h2>
<ul>
{creneaux.map((c) => (
<li key={horaire(c)}>{horaire(c)} (début : {horaire(c, 'debut')})</li>
))}
</ul>
<button className="btn btn-sm btn-outline-primary" onClick={() => setMaintenant(new Date())}>Actualiser l'heure</button>
</div>
);
}
|
- lignes 13-17 : des formateurs de dates, [Intl.DateTimeFormat(langue, options)] : [dateStyle] et [timeStyle] donnent des formats tout faits ([full], [long], [medium], [short]) ; on peut aussi détailler les parties à afficher ([weekday], [day], [month], [year]). [timeZone: 'UTC'] : la date [2026-10-05T00:00:00Z] doit rester le 5 octobre, quel que soit le fuseau horaire du navigateur ;
- lignes 19-21 : des formateurs de nombres : un montant en euros, un nombre sans décimale, un pourcentage ;
- lignes 39-56 : les lignes du tableau : le code, et sa valeur. La dernière enchaîne deux fonctions ;
- ligne 65 : le code affiché entre accolades ;
- lignes 72-74 : [Object.entries(objet)] donne les couples [clé, valeur] d'un objet, qu'on parcourt avec [map] ;
- lignes 79-81 : la fonction maison [horaire].

Les formateurs [Intl] sont créés une fois pour toutes, hors du composant : les fabriquer coûte plus cher que de s'en servir. La langue y est écrite « en dur » ([fr-FR]) ; l'exemple 21 montrera comment suivre la langue choisie par l'utilisateur.