8. Dialoguer avec le serveur
Les exemples de ce chapitre interrogent le serveur JSON [RdvMedecins] : il doit être lancé ([npm run start:dev] dans le dossier [rdvmedecins-nestjs-json]), sur le port 8080, en même temps que le serveur de développement de [Vite], sur le port 5173.
8.1. Exemple 22 : une première requête HTTP
8.1.1. Le proxy du serveur de développement
La page de l'exemple vient de http://localhost:5173 (le serveur de développement de [Vite]) ; l'API est sur http://localhost:8080. Pour un navigateur, ce sont deux origines différentes (le port fait partie de l'origine). Or, par sécurité, un navigateur n'autorise pas une page à lire les réponses d'une autre origine, sauf accord explicite du serveur (le mécanisme dit CORS). Et une requête vers une autre origine n'emporte pas les cookies (donc pas le jeton), sauf si le code le demande ([credentials: 'include']) ET que le serveur l'accepte, toujours par CORS.
Plutôt que d'ouvrir le serveur aux autres origines, on fait passer les requêtes de l'API par le serveur de développement de [Vite]. C'est le proxy déclaré dans le fichier [vite.config.ts] des exemples (cf. exemple 01) :
| proxy: {
'/api': 'http://localhost:8080',
},
|
Le navigateur envoie [GET http://localhost:5173/api/captcha] ; le serveur de développement transmet la requête à [http://localhost:8080/api/captcha], puis renvoie la réponse au navigateur. Pour le navigateur, la page et l'API ont la même origine :

En production, il n'y aura plus de serveur de développement : c'est le serveur JSON lui-même qui servira les fichiers du client compilé (cf. chapitre « Déploiement »). Page et API auront encore la même origine.
8.1.2. La requête
[React] n'a pas de client HTTP à lui : on utilise la fonction [fetch] du navigateur, ou une bibliothèque ([axios]...). [fetch] suffit largement.
| // =========================================================================
// 22-fetch/src/App.tsx - une première requête HTTP vers le serveur
// -------------------------------------------------------------------------
// Le serveur JSON RdvMedecins doit être lancé (port 8080). On lui demande
// un captcha : GET /api/captcha -> { "id": "...", "svg": "<svg ...>" }.
//
// - fetch(url) : la fonction du navigateur qui envoie une requête HTTP.
// Elle renvoie une PROMESSE de la réponse ; reponse.json() une promesse
// du corps, converti depuis le JSON. React n'a pas de client HTTP à lui ;
// - l'adresse est RELATIVE (/api/captcha) : la requête part vers le
// serveur de la page, celui de Vite (port 5173), qui la transmet au
// serveur JSON (port 8080) grâce à son proxy (cf. vite.config.ts) ;
// - trois états à afficher : en cours, réussi, en erreur.
// =========================================================================
import { useEffect, useState } from 'react';
// ce que renvoie GET /api/captcha
interface Captcha {
id: string;
svg: string;
}
const styleCaptcha = { display: 'inline-block', border: '1px solid #dee2e6', borderRadius: '0.375rem' };
export default function App() {
const [captcha, setCaptcha] = useState<Captcha | null>(null);
const [enCours, setEnCours] = useState(true);
const [erreur, setErreur] = useState<string | null>(null);
const [nbRequetes, setNbRequetes] = useState(0);
// chaque clic sur "Autre image" change ce numéro, ce qui relance l'effet
const [demande, setDemande] = useState(0);
useEffect(() => {
// AbortController : le moyen d'ANNULER une requête en cours
const annulation = new AbortController();
setEnCours(true);
setErreur(null);
fetch('/api/captcha', { signal: annulation.signal })
.then(async (reponse) => {
// fetch ne rejette PAS une réponse d'erreur (4xx, 5xx) : on teste ok
if (!reponse.ok) {
throw new Error(`le serveur a répondu ${reponse.status} ${reponse.statusText}`);
}
const c = (await reponse.json()) as Captcha;
setCaptcha(c);
setNbRequetes((n) => n + 1);
setEnCours(false);
})
.catch((e: Error) => {
if (e.name === 'AbortError') return; // requête annulée : rien à afficher
setErreur(e.message);
setCaptcha(null);
setEnCours(false);
});
// le nettoyage ANNULE la requête si l'effet est relancé ou si le
// composant disparaît avant la réponse. En développement, StrictMode
// exécute l'effet deux fois au démarrage : la première requête est
// annulée (onglet Réseau : "canceled"), seule la seconde compte.
return () => annulation.abort();
}, [demande]);
return (
<div className="container py-4" style={{ maxWidth: 640 }}>
<h1 className="h3 mb-3">Exemple 22 - une première requête HTTP</h1>
<div className="card p-3">
{enCours && <p className="text-muted">Chargement...</p>}
{erreur && (
<div className="alert alert-danger mb-3">
Impossible d'obtenir un captcha : {erreur}.<br />
Le serveur JSON RdvMedecins est-il lancé (port 8080) ?
</div>
)}
{captcha && !enCours && (
<>
{/* dangerouslySetInnerHTML : insérer une chaîne comme du HTML */}
<div className="mb-2"><div style={styleCaptcha} dangerouslySetInnerHTML={{ __html: captcha.svg }} /></div>
<p className="small text-muted">id : <code>{captcha.id}</code></p>
</>
)}
<button className="btn btn-primary align-self-start" disabled={enCours} onClick={() => setDemande((d) => d + 1)}>
Autre image
</button>
<p className="small text-muted mt-2 mb-0">
{nbRequetes} requête(s) réussie(s) - ouvrez l'onglet Réseau des outils de développement (F12).
</p>
</div>
</div>
);
}
|
- lignes 19-22 : ce que renvoie [GET /api/captcha] ;
- lignes 27-30 : trois états à afficher : en cours, réussi (le captcha), en erreur ;
- ligne 32 : un numéro de demande : chaque clic sur « Autre image » l'augmente, ce qui relance l'effet ;
- lignes 34-61 : la requête est un effet : elle synchronise le composant avec le serveur, au premier affichage puis à chaque nouvelle demande :
- ligne 36 : un [AbortController] permet d'annuler une requête en cours : on passe son [signal] à [fetch] ;
- ligne 39 : [fetch(url)] envoie une requête GET et renvoie une promesse de la réponse. L'adresse est RELATIVE : la requête part vers le serveur de la page, celui de [Vite], qui la transmet au serveur JSON ;
- lignes 42-44 : ATTENTION : [fetch] ne rejette PAS une réponse d'erreur (4xx, 5xx) ; sa promesse n'est rejetée que si AUCUNE réponse n'arrive (réseau coupé). On teste donc [reponse.ok] (vrai pour un code 2xx) ;
- ligne 45 : [reponse.json()] lit le corps et le convertit depuis le JSON (encore une promesse). [as Captcha] : on affirme à [TypeScript] la forme des données reçues : il ne peut pas le vérifier ;
- lignes 50-55 : une erreur ; [AbortError] : la requête a été annulée, il n'y a rien à afficher ;
- ligne 60 : le nettoyage ANNULE la requête si l'effet est relancé ou si le composant disparaît avant la réponse. En développement, [<StrictMode>] exécute l'effet deux fois au démarrage : la première requête est annulée (l'onglet Réseau l'indique « canceled »), seule la seconde compte ;
- ligne 77 : l'image est un morceau de SVG, une chaîne à insérer comme du HTML. [{captcha.svg}] afficherait le texte [<svg ...>], échappé. [dangerouslySetInnerHTML] insère la chaîne telle quelle : son nom rappelle le danger. À n'utiliser QUE pour un contenu sûr : ce SVG est fabriqué par NOTRE serveur. Avec une saisie d'utilisateur, ce serait ouvrir la porte à l'injection de scripts ;
- ligne 81 : une nouvelle demande : l'effet est relancé.

Si le serveur JSON est arrêté, le proxy de [Vite] répond 500 : « Impossible d'obtenir un captcha : le serveur a répondu 500 Internal Server Error ».
8.2. Exemple 23 : connexion, cookie et session
| // =========================================================================
// 23-connexion/src/App.tsx - se connecter au serveur RdvMedecins
// -------------------------------------------------------------------------
// 1. au démarrage : GET /api/auth/moi - sommes-nous déjà connectés ?
// (après un F5, le client a tout oublié ; le serveur, lui, lit le cookie) ;
// 2. connexion : POST /api/auth/login, corps JSON { login, password,
// captchaId, captcha }. En cas de succès, le serveur dépose le cookie
// httpOnly du jeton JWT : notre code ne le voit JAMAIS (essayez
// document.cookie dans la console : il n'y est pas) ;
// 3. requête protégée : GET /api/medecins. Le navigateur y joint le cookie
// tout seul (même origine) ;
// 4. déconnexion : POST /api/auth/logout - le serveur efface le cookie.
//
// Le formulaire utilise les ACTIONS de React 19 : <form action={fonction}>.
// Au lieu de gérer onSubmit et chaque champ, on reçoit toutes les valeurs
// du formulaire (FormData), et useActionState mémorise le résultat de la
// dernière action (ici, un message d'erreur) et dit si elle est en cours.
//
// Un cookie appartient à une machine (localhost) et à un chemin (/api) : il
// est donc commun à TOUS les exemples. Une fois connecté ici, les exemples
// 24 et 25 le sont aussi.
// =========================================================================
import { useActionState, useEffect, useState } from 'react';
import { useFormStatus } from 'react-dom';
interface Utilisateur {
login: string;
nom: string;
role: 'ADMIN' | 'DOCTOR' | 'USER';
}
interface Medecin {
id: number;
titre: string;
prenom: string;
nom: string;
}
interface Captcha {
id: string;
svg: string;
}
// la forme de TOUTES les erreurs de l'API
interface ErreurApi {
statusCode: number;
cle: string;
params?: Record<string, string>;
}
// les quelques messages dont cet exemple a besoin (l'exemple 25 utilisera i18next)
const MESSAGES: Record<string, string> = {
'LOGIN.ERROR': 'Login ou mot de passe incorrect.',
'ERRORS.WRONG_CAPTCHA': "Les caractères recopiés ne correspondent pas à l'image.",
'ERRORS.TOO_MANY_ATTEMPTS': 'Trop de tentatives infructueuses. Réessayez plus tard.',
};
// une requête GET, dont la réponse JSON est typée ; une erreur si ce n'est pas un 2xx
async function lire<T>(url: string): Promise<T> {
const reponse = await fetch(url);
if (!reponse.ok) {
throw reponse; // on "lance" la réponse elle-même : son statut dira quoi faire
}
return (await reponse.json()) as T;
}
// le bouton d'envoi : useFormStatus() dit si le formulaire QUI LE CONTIENT
// est en cours d'envoi (il doit donc être un composant à part, placé dedans)
function BoutonConnexion() {
const { pending } = useFormStatus();
return <button className="btn btn-primary" disabled={pending}>{pending ? 'Connexion...' : 'Se connecter'}</button>;
}
// le résultat de la dernière tentative de connexion
interface EtatConnexion {
erreur: string | null;
login: string; // le login tapé, pour le réafficher
}
export default function App() {
const [utilisateur, setUtilisateur] = useState<Utilisateur | null>(null);
const [medecins, setMedecins] = useState<Medecin[]>([]);
const [captcha, setCaptcha] = useState<Captcha | null>(null);
const [pret, setPret] = useState(false); // la vérification de départ est-elle terminée ?
const [erreurServeur, setErreurServeur] = useState<string | null>(null);
async function chargerCaptcha() {
setCaptcha(await lire<Captcha>('/api/captcha'));
}
async function chargerMedecins() {
setMedecins(await lire<Medecin[]>('/api/medecins'));
}
// 1. qui est connecté ? 200 -> l'utilisateur ; 401 -> personne
useEffect(() => {
async function verifierSession() {
try {
setUtilisateur(await lire<Utilisateur>('/api/auth/moi'));
await chargerMedecins();
} catch (e) {
if (!(e instanceof Response) || e.status !== 401) {
setErreurServeur('Le serveur JSON RdvMedecins est-il lancé (port 8080) ?');
} else {
await chargerCaptcha();
}
}
setPret(true);
}
verifierSession();
}, []);
// 2. la connexion : une ACTION, qui reçoit l'état précédent et les
// données du formulaire, et renvoie le nouvel état
async function seConnecter(_precedent: EtatConnexion, donnees: FormData): Promise<EtatConnexion> {
const login = String(donnees.get('login'));
const reponse = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }, // le corps est du JSON
body: JSON.stringify({ login, password: donnees.get('password'), captchaId: captcha?.id, captcha: donnees.get('captcha') }),
});
if (!reponse.ok) {
// le corps JSON de la réponse d'erreur
const erreur = (await reponse.json().catch(() => null)) as ErreurApi | null;
const cle = erreur?.cle ?? 'ERRORS.SERVER_UNREACHABLE';
await chargerCaptcha(); // chaque image ne sert qu'une fois
return { erreur: `${reponse.status} - ${MESSAGES[cle] ?? cle}`, login };
}
setUtilisateur((await reponse.json()) as Utilisateur);
await chargerMedecins();
return { erreur: null, login };
}
const [etat, actionConnexion] = useActionState(seConnecter, { erreur: null, login: 'admin' });
// 4. la déconnexion
async function seDeconnecter() {
await fetch('/api/auth/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
setUtilisateur(null);
setMedecins([]);
await chargerCaptcha();
}
let contenu;
if (!pret) {
contenu = <p className="text-muted">Vérification de la session...</p>;
} else if (utilisateur) {
// connecté
contenu = (
<div className="card p-3">
<p>
Connecté : <strong>{utilisateur.nom}</strong> ({utilisateur.login}), rôle{' '}
<span className="badge text-bg-primary">{utilisateur.role}</span>
</p>
<h2 className="h6">GET /api/medecins</h2>
<ul>
{medecins.map((m) => <li key={m.id}>{m.titre} {m.prenom} {m.nom}</li>)}
</ul>
<p className="small text-muted">Rechargez la page (F5) : vous restez connecté.</p>
<button className="btn btn-outline-secondary align-self-start" onClick={seDeconnecter}>Se déconnecter</button>
</div>
);
} else if (!erreurServeur) {
// pas connecté : le formulaire. Les champs ne sont PAS contrôlés : name
// les désigne dans FormData ; après chaque action, React les remet à
// leur valeur de départ (defaultValue)
contenu = (
<form className="card p-3" action={actionConnexion}>
<div className="mb-2">
<label className="form-label" htmlFor="login">Login</label>
<input id="login" name="login" className="form-control" autoComplete="username" defaultValue={etat.login} />
</div>
<div className="mb-2">
<label className="form-label" htmlFor="password">Mot de passe</label>
<input id="password" name="password" type="password" className="form-control" autoComplete="current-password" />
</div>
<div className="mb-3">
<label className="form-label" htmlFor="captcha">Recopiez les caractères de l'image</label>
<div className="d-flex align-items-center gap-3 mb-2">
<div dangerouslySetInnerHTML={{ __html: captcha?.svg ?? '' }} />
<button type="button" className="btn btn-link btn-sm" onClick={chargerCaptcha}>Autre image</button>
</div>
<input id="captcha" name="captcha" className="form-control" autoComplete="off" />
</div>
<BoutonConnexion />
<p className="small text-muted mt-2 mb-0">Comptes : admin, pelissier, martin... (mot de passe = login).</p>
{etat.erreur && <div className="alert alert-danger mt-3 mb-0">{etat.erreur}</div>}
</form>
);
}
return (
<div className="container py-4" style={{ maxWidth: 640 }}>
<h1 className="h3 mb-3">Exemple 23 - connexion au serveur</h1>
{contenu}
{erreurServeur && <div className="alert alert-danger mt-3">{erreurServeur}</div>}
</div>
);
}
|
Le déroulement (lignes 4-12) : au démarrage, on demande au serveur qui est connecté ; puis connexion, requête protégée, déconnexion.
- lignes 27-47 : quelques types de l'API, dont la forme de TOUTES ses erreurs ;
- lignes 50-54 : les quelques messages dont cet exemple a besoin, pour traduire les clés du serveur (l'exemple 25 utilisera [i18next]) ;
- lignes 57-63 : une petite fonction générique pour les requêtes GET : la réponse JSON, typée ; et, si ce n'est pas un 2xx, on « lance » la réponse elle-même : son statut dira quoi faire ;
- lignes 67-70 : le bouton d'envoi du formulaire. [useFormStatus()] (de [react-dom]) dit si le formulaire QUI LE CONTIENT est en cours d'envoi : ce doit donc être un composant à part, placé dans le formulaire ;
- lignes 94-109 : au démarrage, [GET /api/auth/moi] : sommes-nous déjà connectés ? Après un F5, le client a tout oublié ; le serveur, lui, lit le cookie. 200 : l'utilisateur ; 401 : personne (on charge alors un captcha) ; autre chose : le serveur ne répond pas. Une fonction [async] ne peut pas être l'effet lui-même (un effet renvoie son nettoyage, pas une promesse) : on la déclare dans l'effet, puis on l'appelle ;
- lignes 113-130 : la connexion, écrite comme une action de [React] 19 (cf. plus bas). Elle reçoit l'état précédent et les données du formulaire ([FormData]), et renvoie le nouvel état :
- ligne 114 : [donnees.get(nom)] lit un champ du formulaire, désigné par son attribut [name] ;
- lignes 115-119 : [POST /api/auth/login], avec un corps JSON : [method], l'entête [Content-Type] et le corps, fabriqué par [JSON.stringify]. En cas de succès, le serveur dépose le cookie [httpOnly] du jeton JWT : notre code ne le voit JAMAIS (essayez [document.cookie] dans la console : il n'y est pas) ;
- lignes 120-126 : un échec : le corps JSON de la réponse d'erreur donne la clé du message ; chaque image de captcha ne sert qu'une fois : on en charge une autre ;
- lignes 127-129 : un succès : l'utilisateur, puis une requête protégée, [GET /api/medecins] : le navigateur y joint le cookie tout seul (même origine) ;
- ligne 131 : [useActionState(action, étatInitial)] ([React] 19) renvoie l'état produit par la dernière exécution de l'action, et une version de l'action à donner au formulaire ;
- lignes 134-139 : la déconnexion, [POST /api/auth/logout] : le serveur efface le cookie. Tout POST doit avoir un corps JSON (le serveur refuse les autres, cf. chapitre précédent) : ['{}'] ;
- ligne 165 : [<form action={fonction}>] : depuis [React] 19, l'attribut [action] d'un formulaire peut recevoir une fonction. À l'envoi, [React] empêche l'envoi par le navigateur, réunit les champs dans un [FormData], et appelle la fonction, pendant laquelle [useFormStatus] indique « en cours ». Plus besoin de [onSubmit], de [preventDefault], ni d'un état par champ ;
- lignes 168-180 : les champs ne sont PAS contrôlés : [name] les désigne dans le [FormData]. Après chaque action, [React] remet le formulaire à zéro : chaque champ reprend sa valeur de départ ([defaultValue]). Le mot de passe et le captcha sont donc vidés ; le login reprend la valeur rangée dans l'état par l'action ([etat.login]), c'est-à-dire celle qui vient d'être tapée ;
- ligne 184 : le message d'erreur produit par la dernière action.
Un cookie appartient à une machine (localhost) et à un chemin ([/api]) : il est donc commun à TOUS les exemples. Une fois connecté ici, les exemples 24 et 25 le sont aussi.

Un mauvais mot de passe : le login est conservé, une nouvelle image est proposée :

Connecté comme [admin] ; on peut recharger la page (F5) : on reste connecté :

8.3. Exemple 24 : une couche d'accès à l'API
L'exemple 23 répétait les mêmes gestes à chaque requête : préfixe [/api], entête JSON, test de [reponse.ok], lecture du corps d'erreur... Les exemples 24 et 25 les regroupent dans le dossier [commun/], dont l'étude de cas reprendra le principe.
8.3.1. Les types
| // =========================================================================
// commun/types.ts - les objets JSON échangés avec l'API RdvMedecins
// (cf. le chapitre sur la boîte noire, qui décrit chaque route)
// =========================================================================
export type Role = 'ADMIN' | 'DOCTOR' | 'USER';
// POST /api/auth/login, GET /api/auth/moi
export interface Utilisateur {
login: string;
nom: string;
role: Role;
}
// GET /api/captcha
export interface Captcha {
id: string;
svg: string;
}
// un médecin ou un client (GET /api/medecins, GET /api/clients...)
export interface Personne {
id: number;
version: number;
titre: string;
prenom: string;
nom: string;
login?: string; // seulement pour l'ADMIN
}
export interface Identite {
titre: string;
prenom: string;
nom: string;
}
// une ligne de GET /api/agenda
export interface CreneauAgenda {
id: number;
hdebut: number;
mdebut: number;
hfin: number;
mfin: number;
libre: boolean;
patient: Identite | null;
moi: boolean;
reservable: boolean;
idRv: number | null;
}
// GET /api/agenda?idMedecin=...&jour=...
export interface Agenda {
medecin: Personne;
jour: string;
creneaux: CreneauAgenda[];
info: string | null;
}
|
La traduction, en interfaces [TypeScript], de l'API décrite au chapitre précédent. Une ligne de l'agenda ([CreneauAgenda]) décrit un créneau ET ce que l'utilisateur connecté a le droit d'en faire.
8.3.2. Les erreurs
| // =========================================================================
// commun/erreur-api.ts - UNE classe pour toutes les erreurs de l'API
// -------------------------------------------------------------------------
// Toutes les réponses d'erreur du serveur RdvMedecins ont la même forme :
// { "statusCode": 409, "cle": "ERRORS.LOGIN_TAKEN", "params": {...}, "champs": {...} }
// Mais une requête peut aussi échouer AUTREMENT : pas de réponse du tout
// (réseau coupé : fetch lance une TypeError), ou une réponse qui ne vient
// pas de notre serveur (une page d'erreur du proxy de Vite quand le
// serveur JSON est arrêté). On convertit tout cela en UNE classe,
// ErreurApi, qui porte la clé de traduction envoyée par le serveur (et,
// s'il y en a, ses paramètres et l'erreur de chaque champ).
// =========================================================================
export class ErreurApi extends Error {
readonly statusCode: number; // 0 : le serveur n'a pas répondu
readonly cle: string; // ex : 'ERRORS.LOGIN_TAKEN'
readonly params: Record<string, string>;
readonly champs: Record<string, string>;
constructor(statusCode: number, cle: string, params: Record<string, string> = {}, champs: Record<string, string> = {}) {
super(cle);
this.name = 'ErreurApi';
this.statusCode = statusCode;
this.cle = cle;
this.params = params;
this.champs = champs;
}
}
// une réponse d'erreur (4xx, 5xx) -> une ErreurApi
export async function depuisReponse(reponse: Response): Promise<ErreurApi> {
// le corps JSON de l'erreur, s'il existe et a la bonne forme
let corps: { cle?: string; params?: Record<string, string>; champs?: Record<string, string> } = {};
try {
const json: unknown = await reponse.json();
if (typeof json === 'object' && json !== null) {
corps = json;
}
} catch {
// pas du JSON : ce n'est pas notre serveur qui a répondu
}
// pas de clé : le proxy ou le réseau ont répondu à sa place
const cle = corps.cle ?? (reponse.status >= 500 ? 'ERRORS.SERVER_UNREACHABLE' : 'ERRORS.SERVER_ERROR');
return new ErreurApi(reponse.status, cle, corps.params, corps.champs);
}
|
- lignes 14-28 : UNE classe pour toutes les erreurs de l'API : le statut HTTP (0 : pas de réponse), la clé de traduction envoyée par le serveur, ses paramètres, et l'erreur de chaque champ d'un formulaire refusé. Elle hérite d'[Error] : on peut la lancer ([throw]) et la reconnaître ([instanceof ErreurApi]). On n'utilise pas les « paramètres propriétés » de [TypeScript] ([constructor(public readonly cle: string)]) : l'option [erasableSyntaxOnly] du [tsconfig.json] les interdit, parce qu'ils produisent du code ;
- lignes 31-45 : une réponse d'erreur devient une [ErreurApi]. Le corps n'est pas toujours du JSON de notre serveur : quand le serveur JSON est arrêté, c'est le proxy de [Vite] qui répond, sans corps JSON. Pas de clé : un statut 500 ou plus signifie « serveur injoignable ».
8.3.3. Les requêtes
| // =========================================================================
// commun/api.ts - des requêtes vers l'API, en une ligne
// -------------------------------------------------------------------------
// Dans l'exemple 23, chaque requête répétait les mêmes gestes : préfixe
// /api, entête JSON, test de response.ok, lecture du corps d'erreur... On
// les regroupe ici :
//
// const medecins = await api.get<Personne[]>('/medecins');
// await api.post('/rendez-vous', { jour, idCreneau, idClient });
//
// - en cas de succès, la promesse renvoie le corps JSON, TYPÉ ;
// - en cas d'échec, elle est REJETÉE avec une ErreurApi.
// Toutes les requêtes passent par la fonction requete : c'est l'endroit
// où l'on traiterait un problème COMMUN à toutes (cf. l'étude de cas et la
// réponse 401). Utilisé par les exemples 24 et 25.
// =========================================================================
import { depuisReponse, ErreurApi } from './erreur-api';
export async function requete<T>(methode: string, url: string, corps?: unknown): Promise<T> {
let reponse: Response;
try {
reponse = await fetch(`/api${url}`, {
method: methode,
// un corps JSON (le serveur refuse un POST qui n'en est pas un)
headers: corps === undefined ? {} : { 'Content-Type': 'application/json' },
body: corps === undefined ? undefined : JSON.stringify(corps),
});
} catch {
// fetch n'a obtenu AUCUNE réponse (réseau, serveur arrêté)
throw new ErreurApi(0, 'ERRORS.SERVER_UNREACHABLE');
}
if (!reponse.ok) {
throw await depuisReponse(reponse);
}
return (await reponse.json()) as T;
}
export const api = {
get: <T>(url: string) => requete<T>('GET', url),
// un POST a toujours un corps JSON : {} par défaut
post: <T>(url: string, corps: unknown = {}) => requete<T>('POST', url, corps),
put: <T>(url: string, corps: unknown) => requete<T>('PUT', url, corps),
delete: <T>(url: string, corps?: unknown) => requete<T>('DELETE', url, corps),
};
|
- lignes 20-37 : TOUTES les requêtes passent par cette fonction générique :
- lignes 23-28 : le préfixe [/api], et, s'il y a un corps, l'entête et le JSON ;
- lignes 29-32 : aucune réponse : une [ErreurApi] de statut 0 ;
- lignes 33-35 : une réponse d'erreur : une [ErreurApi] ;
- ligne 36 : un succès : le corps, typé ;
- lignes 39-45 : des raccourcis, une fonction par méthode HTTP. Un POST a toujours un corps JSON : [{}] par défaut.
C'est l'endroit où l'on traiterait un problème COMMUN à toutes les requêtes : l'étude de cas y traitera la réponse 401 (session expirée).
8.3.4. Une barre de connexion
| // =========================================================================
// commun/BarreConnexion.tsx - connexion / déconnexion, en une barre
// -------------------------------------------------------------------------
// Le formulaire de l'exemple 23, réduit à une barre et réécrit avec api.ts.
// Le composant appelle onUtilisateurChange avec l'utilisateur connecté (ou
// null) chaque fois que celui-ci change - y compris au démarrage, après
// avoir interrogé GET /api/auth/moi.
// =========================================================================
import { useEffect, useEffectEvent, useState, type FormEvent } from 'react';
import { api } from './api';
import { ErreurApi } from './erreur-api';
import type { Captcha, Utilisateur } from './types';
import './BarreConnexion.css';
interface Props {
onUtilisateurChange: (utilisateur: Utilisateur | null) => void;
}
export default function BarreConnexion({ onUtilisateurChange }: Props) {
const [utilisateur, setUtilisateur] = useState<Utilisateur | null>(null);
const [captcha, setCaptcha] = useState<Captcha | null>(null);
const [login, setLogin] = useState('admin');
const [password, setPassword] = useState('');
const [reponseCaptcha, setReponseCaptcha] = useState('');
const [erreur, setErreur] = useState<string | null>(null);
function changer(u: Utilisateur | null) {
setUtilisateur(u);
onUtilisateurChange(u);
}
async function nouveauCaptcha() {
setCaptcha(await api.get<Captcha>('/captcha'));
setReponseCaptcha('');
}
// au démarrage : sommes-nous déjà connectés ?
const demarrer = useEffectEvent(async () => {
try {
changer(await api.get<Utilisateur>('/auth/moi'));
} catch {
changer(null); // 401 : personne n'est connecté
await nouveauCaptcha().catch(() => setErreur('Le serveur JSON est-il lancé (port 8080) ?'));
}
});
useEffect(() => {
demarrer();
}, []);
async function seConnecter(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setErreur(null);
try {
const u = await api.post<Utilisateur>('/auth/login', { login, password, captchaId: captcha?.id, captcha: reponseCaptcha });
setPassword('');
changer(u);
} catch (err) {
setErreur(err instanceof ErreurApi ? `${err.statusCode} ${err.cle}` : String(err));
await nouveauCaptcha();
}
}
async function seDeconnecter() {
await api.post('/auth/logout');
changer(null);
await nouveauCaptcha();
}
return (
<div className="alert alert-secondary d-flex flex-wrap align-items-center gap-2 py-2">
{utilisateur ? (
<>
<span>
Connecté : <strong>{utilisateur.nom}</strong> <span className="badge text-bg-primary ms-1">{utilisateur.role}</span>
</span>
<button className="btn btn-sm btn-outline-secondary ms-auto" onClick={seDeconnecter}>Se déconnecter</button>
</>
) : (
<form className="d-flex flex-wrap align-items-center gap-2 w-100" onSubmit={seConnecter}>
<input className="form-control form-control-sm w-auto" placeholder="login" autoComplete="username" value={login}
onChange={(e) => setLogin(e.target.value)} />
<input type="password" className="form-control form-control-sm w-auto" placeholder="mot de passe"
autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} />
{/* l'image SVG du captcha, fabriquée par NOTRE serveur (cf. exemple 22) */}
<div className="barre-captcha" dangerouslySetInnerHTML={{ __html: captcha?.svg ?? '' }} />
<input className="form-control form-control-sm w-auto" placeholder="captcha" autoComplete="off" size={8}
value={reponseCaptcha} onChange={(e) => setReponseCaptcha(e.target.value)} />
<button className="btn btn-sm btn-primary">Se connecter</button>
{erreur && <span className="text-danger small">{erreur}</span>}
</form>
)}
</div>
);
}
|
Le formulaire de l'exemple 23, réduit à une barre et réécrit avec [api.ts] (et avec des champs contrôlés, pour comparer) :
- ligne 17 : le composant prévient son parent chaque fois que l'utilisateur connecté change ;
- lignes 39-46 et 47-49 : au démarrage, [GET /api/auth/moi]. La fonction appelée par l'effet prévient le parent : [useEffectEvent] évite d'en faire une dépendance (cf. exemple 13) ;
- ligne 55 : une requête en une ligne ; en cas d'échec, une [ErreurApi] ;
- ligne 86 : l'image du captcha, réduite à la hauteur de la barre par une petite feuille de style :
| /* l'image SVG du captcha, insérée telle quelle dans la page : on la réduit
à la hauteur de la barre. Une feuille de style importée s'applique à
TOUTE la page : la classe .barre-captcha la limite à notre élément. */
.barre-captcha svg {
display: block;
height: 32px;
width: auto;
}
|
8.3.5. La couche d'accès à l'API
| // =========================================================================
// 24-couche-api/src/rdvmedecins.ts - la couche d'accès à l'API
// -------------------------------------------------------------------------
// Une fonction par route de l'API, typée. Les composants ne connaissent ni
// les URL, ni fetch, ni le format JSON : ils appellent des fonctions.
// C'est l'équivalent, côté client, de la couche DAO côté serveur : si l'API
// change (une URL, un nom de champ), on ne modifie que ce fichier.
// =========================================================================
import { api } from '../../commun/api';
import type { Agenda, Personne } from '../../commun/types';
export const rdvmedecins = {
getMedecins: () => api.get<Personne[]>('/medecins'),
getClients: () => api.get<Personne[]>('/clients'),
// URLSearchParams fabrique "idMedecin=1&jour=2030-01-15", en encodant ce qu'il faut
getAgenda: (idMedecin: number, jour: string) =>
api.get<Agenda>(`/agenda?${new URLSearchParams({ idMedecin: String(idMedecin), jour })}`),
reserver: (jour: string, idCreneau: number, idClient?: number) =>
api.post<{ id: number }>('/rendez-vous', { jour, idCreneau, idClient }),
annuler: (idRv: number) => api.delete<Record<string, never>>(`/rendez-vous/${idRv}`),
};
|
Une fonction par route de l'API, typée. Les composants ne connaissent ni les URL, ni [fetch], ni le format JSON : ils appellent des fonctions. C'est l'équivalent, côté client, de la couche DAO côté serveur : si l'API change (une URL, un nom de champ), on ne modifie que ce fichier. [URLSearchParams] (ligne 18) fabrique la partie [idMedecin=1&jour=2030-01-15] d'une adresse, en encodant ce qu'il faut.
8.3.6. TanStack Query
Lire des données d'un serveur dans un effet, comme à l'exemple 22, oblige à tout gérer : l'état « en cours », l'erreur, les réponses périmées, l'annulation, les requêtes identiques envoyées deux fois, les données déjà reçues qu'on pourrait réutiliser... [TanStack Query] (anciennement [React Query]), la bibliothèque la plus utilisée pour cela, s'en charge.
| // =========================================================================
// 24-couche-api/src/main.tsx
// -------------------------------------------------------------------------
// TanStack Query (anciennement React Query) : la bibliothèque la plus
// utilisée pour LIRE des données d'un serveur dans une application React.
// Le QueryClient garde en mémoire (en "cache") les réponses déjà reçues ;
// <QueryClientProvider> le fournit à toute l'application (un contexte,
// cf. exemple 14).
// =========================================================================
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import 'bootstrap/dist/css/bootstrap.min.css';
import App from './App';
const queryClient = new QueryClient({
// en cas d'échec, pas de nouvelle tentative : on veut voir l'erreur
defaultOptions: { queries: { retry: false } },
});
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
);
|
- ligne 17 : le client de [TanStack Query], qui garde en mémoire (en « cache ») les réponses déjà reçues ; [retry: false] : en cas d'échec, pas de nouvelle tentative (par défaut, il en fait trois) ;
- ligne 24 : il est fourni à toute l'application, par un contexte (cf. exemple 14).
8.3.7. La page
| // =========================================================================
// 24-couche-api/src/App.tsx - un agenda, avec la couche d'accès à l'API
// -------------------------------------------------------------------------
// Connectez-vous (barre du haut), choisissez un médecin et un jour : l'agenda
// s'affiche. ADMIN et DOCTOR peuvent réserver pour un patient et annuler ;
// un patient (martin...) réserve pour lui-même. Chaque action se termine par
// un rechargement de l'agenda : c'est le SERVEUR qui fait foi.
//
// useQuery({ queryKey, queryFn }) : une LECTURE de données, décrite par
// - queryFn : la fonction qui les obtient (une promesse) ;
// - queryKey : ce qui IDENTIFIE ces données : ['agenda', idMedecin, jour].
// TanStack Query appelle queryFn au départ, puis chaque fois que la clé
// CHANGE (un autre médecin, un autre jour). Il renvoie un objet : data (la
// réponse), isPending, isFetching, error... et refetch() pour relire.
// Il gère aussi ce qu'il faut écrire à la main avec useEffect : annuler
// une réponse périmée, ne pas envoyer deux fois la même requête, garder
// les réponses en cache.
// =========================================================================
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import BarreConnexion from '../../commun/BarreConnexion';
import { ErreurApi } from '../../commun/erreur-api';
import type { CreneauAgenda, Utilisateur } from '../../commun/types';
import { rdvmedecins } from './rdvmedecins';
function demain(): string {
const d = new Date(Date.now() + 24 * 3600 * 1000);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function horaire(c: CreneauAgenda): string {
const hm = (h: number, m: number) => `${h}:${String(m).padStart(2, '0')}`;
return `${hm(c.hdebut, c.mdebut)} - ${hm(c.hfin, c.mfin)}`;
}
// une erreur -> un texte (sa clé, faute de dictionnaire ici)
function texteErreur(e: unknown): string {
return e instanceof ErreurApi ? `${e.statusCode} ${e.cle}` : String(e);
}
export default function App() {
const [utilisateur, setUtilisateur] = useState<Utilisateur | null>(null);
const [idMedecin, setIdMedecin] = useState<number | null>(null);
const [jour, setJour] = useState(demain);
const [idClient, setIdClient] = useState<number | null>(null);
const [message, setMessage] = useState<{ type: 'success' | 'danger'; texte: string } | null>(null);
const connecte = utilisateur !== null;
const gestionnaire = connecte && utilisateur.role !== 'USER';
// les médecins, puis (ADMIN, DOCTOR) les clients : relus quand
// l'utilisateur change (il fait partie de la clé)
const medecins = useQuery({
queryKey: ['medecins', utilisateur?.login],
queryFn: rdvmedecins.getMedecins,
enabled: connecte, // false : pas de requête (personne de connecté)
});
const clients = useQuery({
queryKey: ['clients', utilisateur?.login],
queryFn: rdvmedecins.getClients,
enabled: gestionnaire, // un USER recevrait 403
});
// l'agenda : la clé contient le médecin et le jour
const agenda = useQuery({
queryKey: ['agenda', idMedecin, jour, utilisateur?.login],
queryFn: () => rdvmedecins.getAgenda(idMedecin!, jour),
enabled: connecte && idMedecin !== null,
});
// un nouveau choix de médecin ou de jour efface le message précédent
function choisirMedecin(id: number) {
setIdMedecin(id);
setMessage(null);
}
function choisirJour(j: string) {
setJour(j);
setMessage(null);
}
async function reserver(c: CreneauAgenda) {
try {
await rdvmedecins.reserver(jour, c.id, gestionnaire ? (idClient ?? undefined) : undefined);
setMessage({ type: 'success', texte: `Créneau ${horaire(c)} réservé.` });
} catch (e) {
setMessage({ type: 'danger', texte: texteErreur(e) });
}
agenda.refetch();
}
async function annuler(c: CreneauAgenda) {
try {
await rdvmedecins.annuler(c.idRv!);
setMessage({ type: 'success', texte: `Rendez-vous de ${horaire(c)} annulé.` });
} catch (e) {
setMessage({ type: 'danger', texte: texteErreur(e) });
}
agenda.refetch();
}
const a = agenda.data;
return (
<div className="container py-4">
<h1 className="h3 mb-3">Exemple 24 - une couche d'accès à l'API</h1>
<BarreConnexion onUtilisateurChange={setUtilisateur} />
{connecte && (
<>
<div className="row g-2 mb-3">
<div className="col-sm-5">
<select className="form-select" value={idMedecin ?? ''} onChange={(e) => choisirMedecin(Number(e.target.value))}>
<option value="" disabled>-- choisir un médecin --</option>
{medecins.data?.map((m) => <option key={m.id} value={m.id}>{m.titre} {m.prenom} {m.nom}</option>)}
</select>
</div>
<div className="col-sm-3">
<input type="date" className="form-control" value={jour} onChange={(e) => choisirJour(e.target.value)} />
</div>
{gestionnaire && (
<div className="col-sm-4">
<select className="form-select" value={idClient ?? ''}
onChange={(e) => setIdClient(e.target.value === '' ? null : Number(e.target.value))}>
<option value="">-- patient à inscrire --</option>
{clients.data?.map((c) => <option key={c.id} value={c.id}>{c.titre} {c.prenom} {c.nom}</option>)}
</select>
</div>
)}
</div>
{message && <div className={`alert alert-${message.type}`}>{message.texte}</div>}
{agenda.error && <div className="alert alert-danger">{texteErreur(agenda.error)}</div>}
{agenda.isFetching && <p className="small text-muted">chargement de l'agenda...</p>}
{a && (
<>
{/* info : la clé envoyée par le serveur quand un patient a déjà un rendez-vous à venir */}
{a.info && <div className="alert alert-info">{a.info}</div>}
<table className="table table-sm align-middle">
<caption className="caption-top">
Agenda du {a.jour} - {a.medecin.titre} {a.medecin.prenom} {a.medecin.nom}
</caption>
<tbody>
{a.creneaux.map((c) => (
<tr key={c.id}>
<td>{horaire(c)}</td>
<td>
{c.libre ? (
<span className="badge text-bg-success">Libre</span>
) : c.patient ? (
<span className={`badge ${c.moi ? 'text-bg-primary' : 'text-bg-secondary'}`}>
{c.patient.titre} {c.patient.prenom} {c.patient.nom}
</span>
) : (
<span className="badge text-bg-secondary">Réservé</span>
)}
</td>
<td className="text-end">
{/* les boutons suivent les droits calculés par le serveur */}
{c.reservable && <button className="btn btn-sm btn-outline-success" onClick={() => reserver(c)}>Réserver</button>}
{c.idRv !== null && <button className="btn btn-sm btn-outline-danger" onClick={() => annuler(c)}>Annuler</button>}
</td>
</tr>
))}
</tbody>
</table>
</>
)}
</>
)}
</div>
);
}
|
- lignes 54-58 : [useQuery({ queryKey, queryFn, enabled })] décrit une LECTURE de données :
- [queryFn] : la fonction qui les obtient, une promesse ;
- [queryKey] : ce qui IDENTIFIE ces données, un tableau. [TanStack Query] appelle [queryFn] au départ, puis chaque fois que la clé CHANGE ; deux composants qui demandent la même clé partagent la même requête et la même réponse. On y met l'utilisateur : la liste est relue quand il change ;
- [enabled: false] : pas de requête (personne n'est connecté) ;
- le résultat est un objet : [data] (la réponse, [undefined] tant qu'elle n'est pas arrivée), [isPending], [isFetching] (une requête est en cours), [error], et [refetch()] pour relire ;
- lignes 59-63 : la liste des patients, seulement pour ADMIN et DOCTOR (un USER recevrait 403) ;
- lignes 66-70 : l'agenda : la clé contient le médecin et le jour ; changer l'un ou l'autre envoie une nouvelle requête. Une réponse périmée est ignorée automatiquement ;
- lignes 82-90 : une réservation : [POST /api/rendez-vous], puis [agenda.refetch()] : on relit l'agenda. C'est le SERVEUR qui fait foi ;
- ligne 106 : la barre de connexion prévient la page ;
- ligne 114 : les médecins, s'ils sont arrivés ([?.] : rien si [data] est [undefined]) ;
- ligne 133 : une requête de l'agenda est en cours ;
- lignes 160-161 : les boutons suivent les droits calculés par le serveur ;
- ligne 138 : [info] : la clé envoyée par le serveur quand un patient a déjà un rendez-vous à venir (affichée brute : pas de dictionnaire ici).
Connecté comme [admin], on choisit Mme PELISSIER, puis un patient, et l'on réserve le premier créneau :

Connecté comme [martin], on réserve un créneau : le patient voit son nom, mais celui d'un autre patient ne lui est jamais envoyé ; il n'a plus de bouton « Réserver » (un seul rendez-vous à venir) :

Pour les écritures (réserver, annuler), [TanStack Query] propose aussi [useMutation], qui gère l'état « en cours » et l'erreur d'une modification. On s'en passe ici : une fonction [async] et un [try / catch] suffisent.
8.4. Exemple 25 : les erreurs renvoyées par le serveur
La gestion des clients (compte [admin]). Ce formulaire ne fait VOLONTAIREMENT aucune validation dans le navigateur : tout est envoyé au serveur, qui répond, en cas de refus, par une erreur JSON :
| 400 { cle: 'ERRORS.INVALID_FORM', champs: { nom: 'ERRORS.LASTNAME_REQUIRED' } }
409 { cle: 'ERRORS.PATIENT_ALREADY_EXISTS', champs: { prenom: ..., nom: ... } }
409 { cle: 'ERRORS.CONCURRENT_UPDATE' } (verrou optimiste)
|
8.4.1. Les dictionnaires
| {
"FORM": {
"TITRE_AJOUT": "Ajouter un client",
"TITRE_MODIF": "Modifier un client",
"TITRE": "Titre",
"PRENOM": "Prénom",
"NOM": "Nom",
"LOGIN": "Login",
"PASSWORD": "Mot de passe",
"ENREGISTRER": "Enregistrer",
"ANNULER": "Annuler",
"MODIFIER": "Modifier",
"SUPPRIMER": "Supprimer",
"CONFIRMER_SUPPRESSION": "Supprimer le client {{nom}}, son compte et ses rendez-vous ?",
"AJOUTE": "Le client {{nom}} a été ajouté (login : {{login}}).",
"MODIFIE": "Le client {{nom}} a été modifié.",
"SUPPRIME": "Le client {{nom}} a été supprimé.",
"ADMIN_SEULEMENT": "Connectez-vous avec le compte admin : seul le rôle ADMIN gère les clients."
},
"ERRORS": {
"INVALID_FORM": "Le formulaire contient des erreurs.",
"INVALID_TITLE": "Choisissez un titre dans la liste.",
"FIRSTNAME_REQUIRED": "Le prénom est obligatoire.",
"FIRSTNAME_TOO_LONG": "Le prénom ne doit pas dépasser 20 caractères.",
"LASTNAME_REQUIRED": "Le nom est obligatoire.",
"LASTNAME_TOO_LONG": "Le nom ne doit pas dépasser 20 caractères.",
"INVALID_LOGIN": "30 caractères au plus : lettres minuscules non accentuées, chiffres, point, tiret, souligné.",
"PASSWORD_TOO_LONG": "Le mot de passe ne doit pas dépasser 72 caractères.",
"LOGIN_TAKEN": "Ce login est déjà utilisé par un autre compte.",
"PATIENT_ALREADY_EXISTS": "Un client portant ce prénom et ce nom existe déjà.",
"PATIENT_NOT_FOUND": "Ce client n'existe pas (ou plus).",
"CONCURRENT_UPDATE": "Cette fiche a été modifiée entre-temps par un autre utilisateur. Rechargez-la avant de recommencer.",
"FORBIDDEN": "Votre rôle ne vous permet pas cette action.",
"AUTH_REQUIRED": "Connectez-vous d'abord.",
"SESSION_EXPIRED": "Votre session a expiré : reconnectez-vous.",
"SERVER_UNREACHABLE": "Le serveur ne répond pas.",
"SERVER_ERROR": "Une erreur inattendue s'est produite."
}
}
|
Les clés [ERRORS.*] sont celles que renvoie le serveur. Le dictionnaire anglais, [en.json], a les mêmes clés. La configuration d'[i18next] est celle de l'exemple 21, en plus simple :
| // =========================================================================
// 25-erreurs-serveur/src/i18n/i18n.ts - i18next, comme à l'exemple 21
// =========================================================================
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import fr from './fr.json';
import en from './en.json';
i18n.use(initReactI18next).init({
resources: { fr: { translation: fr }, en: { translation: en } },
lng: 'fr',
fallbackLng: 'fr',
interpolation: { escapeValue: false },
});
|
| // =========================================================================
// 25-erreurs-serveur/src/main.tsx
// -------------------------------------------------------------------------
// - i18next (cf. exemple 21) ;
// - la fenêtre de confirmation de l'exemple 16, réutilisée telle quelle.
// =========================================================================
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import 'bootstrap/dist/css/bootstrap.min.css';
import './i18n/i18n';
import App from './App';
import { ConfirmationFournisseur } from '../../16-confirmation/src/confirmation/confirmation';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ConfirmationFournisseur>
<App />
</ConfirmationFournisseur>
</StrictMode>,
);
|
La fenêtre de confirmation de l'exemple 16 est réutilisée telle quelle : on importe son fournisseur.
8.4.2. La page
| // =========================================================================
// 25-erreurs-serveur/src/App.tsx - afficher les erreurs du SERVEUR
// -------------------------------------------------------------------------
// La gestion des clients (compte admin). Ce formulaire ne fait
// VOLONTAIREMENT aucune validation dans le navigateur : tout est envoyé
// au serveur, qui répond, en cas de refus, par une erreur JSON (convertie
// en ErreurApi par commun/api.ts) :
//
// 400 { cle: 'ERRORS.INVALID_FORM', champs: { nom: 'ERRORS.LASTNAME_REQUIRED' } }
// 409 { cle: 'ERRORS.PATIENT_ALREADY_EXISTS', champs: { prenom: ..., nom: ... } }
// 409 { cle: 'ERRORS.CONCURRENT_UPDATE' } (verrou optimiste)
//
// L'erreur de CHAQUE champ (ErreurApi.champs) est rangée dans l'état et
// affichée sous le champ ; son message est la CLÉ envoyée par le serveur,
// que le composant traduit (i18next). Modifier un champ efface son erreur.
// Essayez : un nom vide, un titre "Dr", un homonyme (Alice DURAND), un
// login déjà pris (martin), la même fiche modifiée dans deux onglets...
// =========================================================================
import { useState, useTransition, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { api } from '../../commun/api';
import BarreConnexion from '../../commun/BarreConnexion';
import { ErreurApi } from '../../commun/erreur-api';
import type { Personne, Utilisateur } from '../../commun/types';
import { useConfirmation } from '../../16-confirmation/src/confirmation/confirmation';
interface SaisieClient {
titre: string;
prenom: string;
nom: string;
login: string;
password: string;
version: number;
}
type Champ = keyof SaisieClient;
const VIDE: SaisieClient = { titre: 'Mr', prenom: '', nom: '', login: '', password: '', version: 0 };
interface Message {
cle: string;
params?: Record<string, string>;
}
export default function App() {
const { t, i18n } = useTranslation();
const confirmer = useConfirmation();
const [utilisateur, setUtilisateur] = useState<Utilisateur | null>(null);
const [clients, setClients] = useState<Personne[]>([]);
const estAdmin = utilisateur?.role === 'ADMIN';
// le formulaire : idEdite === null -> création, sinon modification
const [idEdite, setIdEdite] = useState<number | null>(null);
const [saisie, setSaisie] = useState<SaisieClient>(VIDE);
// l'erreur de chaque champ (une CLÉ de traduction), renvoyée par le serveur
const [erreurs, setErreurs] = useState<Partial<Record<Champ, string>>>({});
// le message général de la dernière erreur, et celui d'un succès
const [erreurPage, setErreurPage] = useState<ErreurApi | null>(null);
const [succes, setSucces] = useState<Message | null>(null);
// useTransition : isPending est vrai pendant l'exécution de l'action
// passée à startTransition - ici, l'envoi au serveur
const [envoiEnCours, startTransition] = useTransition();
async function recharger() {
setClients(await api.get<Personne[]>('/clients'));
}
async function surConnexion(u: Utilisateur | null) {
setUtilisateur(u);
setClients([]);
if (u?.role === 'ADMIN') {
await recharger();
}
}
function effacerMessages() {
setErreurs({});
setErreurPage(null);
setSucces(null);
}
function nouveau() {
setIdEdite(null);
setSaisie(VIDE);
effacerMessages();
}
function editer(c: Personne) {
setIdEdite(c.id);
// la VERSION lue avec la fiche repart avec la modification : si quelqu'un
// a modifié la fiche entre-temps, le serveur répondra 409 CONCURRENT_UPDATE
setSaisie({ titre: c.titre, prenom: c.prenom, nom: c.nom, login: c.login ?? '', password: '', version: c.version });
effacerMessages();
}
// un champ modifié : sa valeur change, son erreur disparaît
function modifier(champ: Champ, valeur: string) {
setSaisie((s) => ({ ...s, [champ]: valeur }));
setErreurs((e) => ({ ...e, [champ]: undefined }));
}
function enregistrer(evenement: FormEvent<HTMLFormElement>) {
evenement.preventDefault();
effacerMessages();
// une transition ASYNCHRONE (React 19) : envoiEnCours reste vrai jusqu'à
// la fin de la fonction
startTransition(async () => {
try {
const client =
idEdite === null
? await api.post<Personne>('/clients', saisie)
: await api.put<Personne>(`/clients/${idEdite}`, saisie);
nouveau(); // formulaire vidé, messages effacés...
// ... sauf celui-ci
setSucces({
cle: idEdite === null ? 'FORM.AJOUTE' : 'FORM.MODIFIE',
params: { nom: `${client.prenom} ${client.nom}`, login: client.login ?? '' },
});
await recharger();
} catch (e) {
if (!(e instanceof ErreurApi)) {
throw e;
}
setErreurPage(e); // le message général
setErreurs(e.champs); // l'erreur de chaque champ
}
});
}
async function supprimer(c: Personne) {
const nom = `${c.prenom} ${c.nom}`;
if (!(await confirmer(t('FORM.CONFIRMER_SUPPRESSION', { nom })))) {
return;
}
effacerMessages();
try {
await api.delete(`/clients/${c.id}`);
setSucces({ cle: 'FORM.SUPPRIME', params: { nom } });
} catch (e) {
setErreurPage(e instanceof ErreurApi ? e : null);
}
await recharger();
}
// un champ de saisie, avec son erreur traduite
function champ(nom: 'prenom' | 'nom' | 'login' | 'password', type = 'text') {
const erreur = erreurs[nom];
return (
<>
<label className="form-label" htmlFor={nom}>{t(`FORM.${nom.toUpperCase()}`)}</label>
<input id={nom} type={type} className={`form-control ${erreur ? 'is-invalid' : ''}`} value={saisie[nom]}
autoComplete={type === 'password' ? 'new-password' : 'off'} onChange={(e) => modifier(nom, e.target.value)} />
{/* le message d'une erreur du serveur est une CLÉ : on la traduit */}
{erreur && <div className="invalid-feedback">{t(erreur)}</div>}
</>
);
}
return (
<div className="container py-4">
<div className="d-flex justify-content-between align-items-center mb-3">
<h1 className="h3 mb-0">Exemple 25 - les erreurs renvoyées par le serveur</h1>
<select className="form-select form-select-sm w-auto" value={i18n.language} onChange={(e) => i18n.changeLanguage(e.target.value)}>
<option value="fr">FR</option>
<option value="en">EN</option>
</select>
</div>
<BarreConnexion onUtilisateurChange={surConnexion} />
{utilisateur && !estAdmin && <p className="alert alert-warning">{t('FORM.ADMIN_SEULEMENT')}</p>}
{estAdmin && (
<div className="row g-4">
<div className="col-lg-6">
<table className="table table-sm align-middle">
<tbody>
{clients.map((c) => (
<tr key={c.id}>
<td>{c.titre} {c.prenom} {c.nom}</td>
<td><code>{c.login}</code></td>
<td className="text-end text-nowrap">
<button className="btn btn-sm btn-outline-primary me-1" onClick={() => editer(c)}>{t('FORM.MODIFIER')}</button>
<button className="btn btn-sm btn-outline-danger" onClick={() => supprimer(c)}>{t('FORM.SUPPRIMER')}</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="col-lg-6">
<form className="card p-3" noValidate onSubmit={enregistrer}>
<h2 className="h5">{t(idEdite === null ? 'FORM.TITRE_AJOUT' : 'FORM.TITRE_MODIF')}</h2>
<div className="mb-2">
<label className="form-label" htmlFor="titre">{t('FORM.TITRE')}</label>
{/* une option "Dr" que le serveur refusera : pour voir ERRORS.INVALID_TITLE */}
<select id="titre" className={`form-select ${erreurs.titre ? 'is-invalid' : ''}`} value={saisie.titre}
onChange={(e) => modifier('titre', e.target.value)}>
<option>Mr</option><option>Mme</option><option>Melle</option><option>Dr</option>
</select>
{erreurs.titre && <div className="invalid-feedback">{t(erreurs.titre)}</div>}
</div>
<div className="row g-2 mb-2">
<div className="col-sm-6">{champ('prenom')}</div>
<div className="col-sm-6">{champ('nom')}</div>
</div>
<div className="row g-2 mb-3">
<div className="col-sm-6">{champ('login')}</div>
<div className="col-sm-6">{champ('password', 'password')}</div>
</div>
<div className="d-flex gap-2">
<button className="btn btn-primary" disabled={envoiEnCours}>{t('FORM.ENREGISTRER')}</button>
<button type="button" className="btn btn-secondary" onClick={nouveau}>{t('FORM.ANNULER')}</button>
</div>
{succes && <div className="alert alert-success mt-3 mb-0">{t(succes.cle, succes.params)}</div>}
{/* le message général, et le statut HTTP, pour comprendre */}
{erreurPage && (
<div className="alert alert-danger mt-3 mb-0">
{t(erreurPage.cle, erreurPage.params)}{' '}
<span className="small text-muted">({erreurPage.statusCode} {erreurPage.cle})</span>
</div>
)}
</form>
</div>
</div>
)}
</div>
);
}
|
- lignes 54-55 : le formulaire : [idEdite === null] pour une création, sinon la modification du client [idEdite] ;
- ligne 57 : l'erreur de chaque champ, une CLÉ de traduction, renvoyée par le serveur ;
- ligne 63 : [useTransition] renvoie un indicateur « en cours » et une fonction [startTransition(action)]. Depuis [React] 19, l'action peut être [async] : l'indicateur reste vrai jusqu'à la fin de la fonction, sans qu'on ait à le gérer ;
- lignes 89-95 : la modification d'un client. La VERSION lue avec la fiche repart avec la modification : si quelqu'un a modifié la fiche entre-temps, le serveur répondra 409 [ERRORS.CONCURRENT_UPDATE] (verrou optimiste) ;
- lignes 98-101 : un champ modifié : sa valeur change, son erreur disparaît ;
- lignes 103-129 : l'envoi :
- [POST /api/clients] (création) ou [PUT /api/clients/:id] (modification) ;
- en cas de succès : le formulaire est vidé, et un message traduit, avec ses paramètres ;
- en cas d'[ErreurApi] (lignes 125-126) : le message général, et l'erreur de CHAQUE champ, rangée telle quelle dans l'état : [e.champs] a exactement la forme voulue ;
- lignes 131-144 : la suppression, après confirmation. Le message de confirmation est traduit par [t], avec un paramètre ;
- lignes 147-158 : une fonction qui fabrique un champ de saisie avec son libellé et son erreur : [t(erreur)] traduit la CLÉ envoyée par le serveur ;
- ligne 164 : le choix de la langue : les erreurs déjà affichées se retraduisent, puisque ce sont des clés ;
- ligne 199 : une option « Dr » que le serveur refusera, pour voir [ERRORS.INVALID_TITLE] ;
- ligne 212 : le bouton est désactivé pendant l'envoi ;
- ligne 219 : le message général, et le statut HTTP, pour comprendre.
Connecté comme [admin], un titre « Dr », un nom vide et un login invalide :

Un homonyme (Alice Durand, qui existe déjà) et un login déjà pris ([martin]) : 409, et les deux champs concernés sont signalés :

La même page en anglais : les messages du serveur se retraduisent :

Un ajout réussi :

Puis sa suppression :


Pour voir le verrou optimiste, on ouvre la même fiche dans deux onglets, on l'enregistre dans le premier, puis dans le second : le serveur répond 409 [ERRORS.CONCURRENT_UPDATE].