Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

Chapitre 729 minIntermédiaire

Appeler une API REST en React avec fetch

Par Josselyn C., prof de BTS SIO. Mis à jour le .

À la fin de ce chapitre, tu sauras

  • relier le front à l'API avec le proxy de Vite, sans erreur CORS ;
  • regrouper les appels à l'API dans un module typé qui gère les erreurs HTTP ;
  • charger des données dans un effet en gérant chargement, erreur et annulation ;
  • envoyer une modification depuis un gestionnaire d'événement.

4 exercices corrigés

Pour afficher des données venues d'un serveur, un composant React appelle une API REST avec fetch, range la réponse dans son état et gère trois situations : le chargement, l'erreur et les données reçues. Ce chapitre relie le front d'AppliFrais à son API : configuration du proxy de Vite, module d'appels typé, chargement dans un effet avec annulation, et les pièges qui font perdre des heures (CORS, fetch qui ne rejette pas sur une erreur 404, requêtes en double).

L'API d'AppliFrais

Le front parle à l'API REST d'AppliFrais, que tu construis dans le cours Node.js et Express (créer une API REST CRUD) ; c'est aussi celle qu'appelle l'application mobile du cours Développement mobile. Les principes d'une API (méthodes HTTP, codes de statut, JSON) sont expliqués dans le cours API REST ; ici, on se place côté client. Voici le contrat dont le front a besoin, tel que l'API le respecte :

OpérationRequêteRéponse en cas de succèsErreurs possibles
Se connecterPOST /v1/auth/connexion avec { login, motDePasse }200 + { accessToken, tokenType: "Bearer", expiresIn: 900 }401 (identifiants invalides), 422, 429 (trop de tentatives)
Utilisateur connectéGET /v1/auth/moi200 + { id, login, nom, prenom, role }401
Mes fichesGET /v1/fiches ou GET /v1/fiches?etat=CR200 + { donnees, pagination } : une page de fiches résumées401, 422 (filtre invalide)
Détail d'une ficheGET /v1/fiches/12200 + la fiche, ses lignes de forfait, ses frais hors forfait et son total401, 404
Clôturer une fichePATCH /v1/fiches/12 avec { "etat": "CL" }200 + la fiche détaillée401, 403, 404, 409 (changement d'état interdit)
Ajouter un fraisPOST /v1/fiches/12/frais-hors-forfait avec { date, libelle, montant }201 + le frais créé401, 403 (réservé aux visiteurs), 404, 409 (fiche clôturée), 422 (données invalides)
Supprimer un fraisDELETE /v1/frais-hors-forfait/58204401, 403, 404, 409 (fiche clôturée)

Toutes ces routes, sauf la connexion, exigent l'en-tête Authorization: Bearer <jeton>, avec le jeton d'accès renvoyé par la connexion, valable 15 minutes (authentification JWT). Sans lui, l'API répond 401. Le chapitre 12 explique où le front garde ce jeton, et pourquoi.

La liste des fiches est paginée (paginer une liste) : 20 fiches par page par défaut, 100 au plus avec ?limite=100. Pour Lucas Martin, visiteur, GET /v1/fiches renvoie :

json
{
  "donnees": [
    { "id": 12, "visiteurId": 7, "mois": "2026-10", "etat": "CR", "montantValide": null, "dateModification": "2026-11-02 14:10:38" },
    { "id": 11, "visiteurId": 7, "mois": "2026-09", "etat": "VA", "montantValide": 389.2, "dateModification": "2026-10-05 09:12:44" }
  ],
  "pagination": { "page": 1, "limite": 20, "total": 2, "pages": 1 }
}

Les erreurs suivent le format de la RFC 9457 (des erreurs normalisées), avec le type application/problem+json :

json
{
  "type": "https://api.gsb.example/problemes/fiche-cloturee",
  "title": "Fiche non modifiable",
  "status": 409,
  "detail": "La fiche est à l'état CL : seuls les frais d'une fiche en cours se modifient.",
  "instance": "/v1/fiches/13/frais-hors-forfait",
  "etatActuel": "CL"
}

Une erreur de validation (422) ajoute la liste erreurs des champs en faute, chacun avec son champ et son message (la réponse 422).

Deux serveurs, un proxy

En développement, deux serveurs tournent en même temps :

  • l'API Express, sur http://localhost:3000 ;
  • le front, servi par Vite sur http://localhost:5173.

Si le front appelle directement http://localhost:3000/v1/fiches, le navigateur bloque la réponse avec une erreur CORS, sauf si l'API l'autorise : les deux adresses n'ont pas le même port, donc pas la même origine, et le navigateur interdit par défaut à une page de lire les réponses d'une autre origine (CORS expliqué). Deux solutions :

  1. l'API autorise l'origine du front avec le middleware cors (côté serveur) : c'est ce que fait l'API du cours Node.js avec sa variable CORS_ORIGIN=http://localhost:5173 (le middleware cors) ;
  2. le front fait passer ses appels par son propre serveur : c'est le proxy de Vite.

La seconde est la plus simple en développement, et elle reproduit la configuration de production recommandée, où le front et l'API sont servis sous le même nom de domaine (chapitre 15). Dans vite.config.ts :

ts
// vite.config.ts
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      // En développement, /v1/... est relayé à l'API Express : même origine, pas de CORS.
      '/v1': 'http://localhost:3000',
    },
  },
});

Le front appelle maintenant /v1/fiches, une adresse relative : le navigateur l'envoie à localhost:5173, et Vite la relaie telle quelle à localhost:3000, en-têtes compris. Pour le navigateur, tout vient de la même origine. Aucune page du front ne commence par /v1 : le proxy ne gêne pas le routeur. Redémarre npm run dev après avoir modifié vite.config.ts.

Décrire les données avec TypeScript

On complète src/types.ts avec ce que renvoie l'API :

ts
// src/types.ts (suite)
export type NouveauFrais = Omit<Frais, 'id'>;    // un frais sans id : ce que le front envoie

export type FicheDetail = FicheResume & {
  fraisHorsForfait: Frais[];
};

export type Page<T> = {
  donnees: T[];
  pagination: { page: number; limite: number; total: number; pages: number };
};

export type Role = 'visiteur' | 'comptable';

export type Utilisateur = {
  id: number;
  login: string;
  nom: string;
  prenom: string;
  role: Role;
};

Omit<T, 'cle'> crée un type à partir de T sans la propriété citée ; A & B combine deux types. Ainsi, si la forme d'un Frais change, NouveauFrais suit automatiquement. Page<T> est un type générique : Page<FicheResume> décrit une page de fiches résumées.

NouveauFrais contient exactement date, libelle et montant : l'API refuse tout autre champ avec un 422, car son schéma est une liste blanche (z.strictObject). À l'inverse, les types n'ont pas à décrire tout ce que l'API renvoie, seulement ce dont le front se sert : la liste contient aussi visiteurId et dateModification, le détail ajoute le visiteur, les lignes de forfait et le total (forfaits compris), et chaque frais un booléen justificatif. TypeScript ne s'en plaint pas ; le jour où un écran en a besoin, on ajoute le champ au type.

Un module pour tous les appels

Plutôt que d'écrire fetch dans chaque composant, on regroupe les appels dans un module de service. Les composants n'ont plus à connaître les adresses, les en-têtes ou le format des erreurs, et le jour où l'API change, une seule couche est à modifier. C'est la séparation des responsabilités de l'architecture en couches.

D'abord, une fonction générique qui fait la requête, ajoute le jeton et transforme les erreurs HTTP en exceptions :

ts
// src/api/client.ts : toutes les requêtes vers l'API passent par cette fonction.
const BASE = import.meta.env.VITE_API_URL ?? '/v1';

export class ApiError extends Error {
  status: number;

  constructor(status: number, message: string) {
    super(message);
    this.name = 'ApiError';
    this.status = status;
  }
}

// Le jeton d'accès, gardé en mémoire (chapitre 12) ; null : personne n'est connecté.
let jeton: string | null = null;

export function definirJeton(nouveau: string | null) {
  jeton = nouveau;
}

type Options = {
  method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
  corps?: unknown;
  signal?: AbortSignal;
};

export async function api<T>(chemin: string, { method = 'GET', corps, signal }: Options = {}): Promise<T> {
  const headers: Record<string, string> = { Accept: 'application/json' };
  if (corps !== undefined) headers['Content-Type'] = 'application/json';
  if (jeton) headers.Authorization = `Bearer ${jeton}`;

  const reponse = await fetch(BASE + chemin, {
    method,
    signal,
    headers,
    body: corps === undefined ? undefined : JSON.stringify(corps),
  });
  if (!reponse.ok) {
    // Les erreurs de l'API suivent la RFC 9457 : { type, title, status, detail }, et erreurs pour un 422
    const probleme = await reponse.json().catch(() => null);
    throw new ApiError(reponse.status, messageErreur(reponse.status, probleme));
  }
  if (reponse.status === 204) return undefined as T;
  return (await reponse.json()) as T;
}

type Probleme = { detail?: string; erreurs?: { champ: string; message: string }[] } | null;

// Pour un 422, les vrais messages sont dans erreurs, un par champ ; sinon, le detail du problème.
function messageErreur(status: number, probleme: Probleme): string {
  if (probleme?.erreurs?.length) return probleme.erreurs.map((e) => `${e.champ} : ${e.message}`).join(' ; ');
  return probleme?.detail ?? `Erreur ${status}`;
}

Ce qu'il faut comprendre ligne par ligne :

  • import.meta.env.VITE_API_URL lit une variable d'environnement fournie par Vite (chapitre 15). Si elle n'est pas définie, on utilise /v1, l'adresse relative du proxy.
  • jeton et definirJeton : le module garde le jeton d'accès dans une variable, en mémoire, et l'ajoute dans l'en-tête Authorization de chaque requête quand il existe. La variable n'est pas exportée : le reste du code ne peut que la remplacer, avec definirJeton. C'est la connexion du chapitre 12 qui l'appelle.
  • api<T> est une fonction générique : T est le type de la réponse, choisi à chaque appel (api<FicheDetail>(…)). TypeScript sait alors ce que contient le résultat.
  • if (!reponse.ok) : c'est la ligne la plus importante. fetch ne rejette pas sa promesse pour une erreur HTTP : une réponse 404 ou 500 est une réponse comme une autre, avec ok à false. fetch ne rejette que si la requête n'a pas pu aboutir (serveur injoignable, réseau coupé, blocage CORS, requête annulée). Sans ce test, le code traiterait le corps de l'erreur comme des données (le piège de fetch).
  • .catch(() => null) : si le corps de l'erreur n'est pas du JSON (une page d'erreur HTML d'un serveur intermédiaire), on ne plante pas, on garde un message générique.
  • messageErreur : pour un 422, le detail de l'API reste général (« Un ou plusieurs champs sont invalides. ») ; les messages utiles sont dans erreurs. On les réunit en un seul texte (« libelle : … ; montant : … »), que les formulaires affichent tel quel.
  • status === 204 : une réponse « No Content » n'a pas de corps ; appeler .json() dessus lèverait une erreur.
  • class ApiError garde le code de statut : le composant peut réagir différemment à un 401 (renvoyer vers la connexion) et à un 409 (afficher le message).

Ensuite, une fonction par opération, typée :

ts
// src/api/fiches.ts
import { api } from './client';
import type { EtatFiche, FicheDetail, FicheResume, Frais, NouveauFrais, Page } from '../types';

export async function getFiches(etat?: EtatFiche, signal?: AbortSignal) {
  const params = new URLSearchParams({ limite: '100' });   // la plus grande page que l'API accepte
  if (etat) params.set('etat', etat);
  const page = await api<Page<FicheResume>>(`/fiches?${params}`, { signal });
  return page.donnees;
}

export function getFiche(id: number, signal?: AbortSignal) {
  return api<FicheDetail>(`/fiches/${id}`, { signal });
}

export function ajouterFrais(ficheId: number, frais: NouveauFrais) {
  return api<Frais>(`/fiches/${ficheId}/frais-hors-forfait`, { method: 'POST', corps: frais });
}

export function supprimerFrais(id: number) {
  return api<void>(`/frais-hors-forfait/${id}`, { method: 'DELETE' });
}

URLSearchParams construit la chaîne de requête (limite=100&etat=CL) et encode chaque valeur : indispensable pour une valeur saisie librement, comme une recherche. getFiches demande la plus grande page que l'API accepte, 100 fiches, et ne renvoie que donnees : le reste du front manipule un simple tableau. Pour un visiteur, 100 fiches mensuelles couvrent plus de huit ans. Un comptable, qui voit les fiches de tous les visiteurs, aurait besoin d'une vraie pagination, avec un bouton « Page suivante » qui demande page=2.

Charger les données dans un effet

Charger les fiches à l'affichage de la page, c'est se synchroniser avec un système extérieur (le serveur) : un effet convient. L'effet lance la requête et range le résultat dans l'état : les fiches, ou le message d'erreur, avec le filtre pour lequel il a été obtenu.

tsx
// src/components/ListeFichesFetch.tsx
import { useEffect, useState } from 'react';
import { getFiches } from '../api/fiches';
import type { EtatFiche, FicheResume } from '../types';

type Resultat = {
  etat?: EtatFiche;          // le filtre pour lequel ce résultat a été obtenu
  fiches?: FicheResume[];
  erreur?: string;
};

export function ListeFichesFetch({ etat }: { etat?: EtatFiche }) {
  const [resultat, setResultat] = useState<Resultat | null>(null);

  useEffect(() => {
    const controleur = new AbortController();
    getFiches(etat, controleur.signal)
      .then((fiches) => setResultat({ etat, fiches }))
      .catch((e) => {
        if (e.name !== 'AbortError') setResultat({ etat, erreur: e.message });
      });
    return () => controleur.abort();   // nettoyage : la requête précédente est annulée
  }, [etat]);

  // Pas encore de résultat pour le filtre actuel : le chargement est en cours.
  if (resultat === null || resultat.etat !== etat) return <p role="status">Chargement…</p>;
  if (resultat.erreur) return <p role="alert">Erreur : {resultat.erreur}</p>;

  const fiches = resultat.fiches ?? [];
  if (fiches.length === 0) return <p>Aucune fiche.</p>;

  return (
    <ul>
      {fiches.map((f) => <li key={f.id}>{f.mois}</li>)}
    </ul>
  );
}

Le déroulement :

  1. Premier rendu : resultat vaut null, le composant affiche « Chargement… ».
  2. Après l'affichage, l'effet lance la requête.
  3. La réponse arrive : setResultat range les fiches, ce qui provoque un nouveau rendu, et la liste s'affiche.
  4. Si l'API répond par une erreur, api lève une ApiError : le catch range le message, l'écran affiche l'erreur.
  5. Si etat change (l'utilisateur choisit un autre filtre), le résultat en mémoire correspond à l'ancien filtre : le composant affiche de nouveau « Chargement… ». Le nettoyage annule la requête en cours, puis l'effet en relance une avec le nouveau filtre.

L'indicateur de chargement n'est pas un état : il se déduit (« le résultat que j'ai ne correspond pas au filtre demandé »). C'est l'idée de l'état dérivé du chapitre 4. Beaucoup de tutoriels déclarent trois états (fiches, chargement, erreur) et appellent setChargement(true) au début de l'effet : ça fonctionne, mais la règle set-state-in-effect du plugin ESLint officiel de React le signale (« Calling setState synchronously within an effect »), car chaque changement de filtre provoque alors un rendu supplémentaire inutile.

role="status" et role="alert" font annoncer le chargement et l'erreur par les lecteurs d'écran (chapitre 14).

Pourquoi annuler la requête

Sans AbortController, voici ce qui peut arriver : l'utilisateur choisit le filtre « Clôturées », puis aussitôt « Validées ». Deux requêtes partent. Si la première répond après la seconde (le serveur était plus lent), sa mise à jour de l'état écrase le bon résultat. Selon la façon dont le composant est écrit, l'écran affiche les fiches clôturées sous le filtre « Validées », ou reste bloqué sur « Chargement… ». C'est une situation de compétition (race condition).

L'annulation règle le problème : quand le filtre change, le nettoyage appelle controleur.abort(). fetch rejette alors sa promesse avec une erreur nommée AbortError, que le catch ignore. Seule la dernière requête met à jour l'écran. C'est aussi ce qui rend le double appel du mode strict inoffensif : la première requête est annulée aussitôt, l'onglet Réseau la montre en rouge « (canceled) », et seule la seconde compte.

Envoyer des données : dans le gestionnaire, pas dans un effet

Lire des données parce qu'une page s'affiche : effet. Écrire des données parce que l'utilisateur a cliqué : gestionnaire d'événement.

tsx
async function supprimer(id: number) {
  try {
    await supprimerFrais(id);
    setFrais((liste) => liste.filter((f) => f.id !== id));   // mise à jour locale après succès
  } catch (e) {
    setErreur(e instanceof ApiError ? e.message : 'Le serveur ne répond pas.');
  }
}

<button type="button" onClick={() => supprimer(f.id)}>Supprimer</button>

L'ajout d'un frais par formulaire, avec les Actions de React 19, est au chapitre 8.

Les limites du fetch dans un effet

Le code de ce chapitre fonctionne et il faut savoir l'écrire : c'est ce qu'on attend de toi pour expliquer comment un front charge des données. Mais dans une vraie application, il laisse beaucoup de problèmes sans réponse :

  • Pas de cache : revenir sur la liste après avoir ouvert une fiche relance la requête et réaffiche « Chargement… ».
  • Pas de partage : deux composants qui ont besoin des mêmes fiches font deux requêtes.
  • Pas de mise à jour : après la clôture d'une fiche sur la page de détail, la liste affiche son ancien état jusqu'au prochain chargement.
  • Pas de nouvelle tentative quand le réseau mobile coupe une seconde.
  • Le même code à réécrire (résultat, chargement, erreur, annulation) dans chaque composant qui charge quelque chose.

La documentation de React le dit elle-même : pour charger des données dans une application cliente, utilise une bibliothèque spécialisée. C'est l'objet du chapitre 9 avec TanStack Query.

Exercices

Exercice 1 sur 4 Lire une erreur dans la console Réussi À revoir

Pour chaque message affiché dans la console du navigateur, donne la cause probable et la correction.

  1. Access to fetch at 'http://localhost:3000/v1/fiches' from origin 'http://localhost:5173' has been blocked by CORS policy
  2. GET http://localhost:5173/v1/fiches 502 (Bad Gateway), et dans le terminal de Vite : http proxy error avec ECONNREFUSED
  3. SyntaxError: Unexpected token '<', "<!doctype "... is not valid JSON
  4. L'écran affiche « Erreur : Jeton absent. »
Voir la réponseMasquer la réponse de l'exercice 1
  1. Le front appelle l'API sur une autre origine (autre port), et l'API ne l'autorise pas. Utiliser une adresse relative /v1/... avec le proxy de Vite, ou autoriser l'origine du front côté API avec le middleware cors (CORS_ORIGIN=http://localhost:5173 dans l'API du cours Node.js).
  2. Le proxy de Vite n'arrive pas à joindre l'API : elle n'est pas lancée, ou pas sur le port 3000. Démarrer l'API, vérifier son port.
  3. Le corps reçu est une page HTML, pas du JSON. Cas typique : un fetch écrit à la main, sans en-tête Accept (le navigateur envoie alors */*), vers une adresse qui ne passe pas par le proxy (/api/v1/fiches, copiée d'un ancien tutoriel, au lieu de /v1/fiches). Pour une adresse qu'il ne connaît pas, le serveur de Vite renvoie alors sa page index.html avec un code 200. Avec le module api du cours, qui envoie Accept: application/json, la même faute donne une réponse 404 et l'écran affiche « Erreur 404 ». Dans les deux cas, vérifie l'URL dans l'onglet Réseau et la configuration du proxy.
  4. L'API a répondu 401, et « Jeton absent. » est le detail de son problème : la requête est partie sans en-tête Authorization, personne n'est connecté. Se connecter (chapitre 12), ou utiliser d'ici là la connexion temporaire de ce chapitre.

Tu avais trouvé ?

Exercice 2 sur 4 Le détail d'une fiche Réussi À revoir

Sur le modèle de ListeFichesFetch, écris le composant DetailFiche({ id }: { id: number }) qui charge getFiche(id) et affiche le mois, l'état et le nombre de frais hors forfait. Gère le chargement, l'erreur et l'annulation. Quel message affiche-t-il si la fiche n'existe pas ?

Voir la réponseMasquer la réponse de l'exercice 2
tsx
import { useEffect, useState } from 'react';
import { getFiche } from '../api/fiches';
import type { FicheDetail } from '../types';

export function DetailFiche({ id }: { id: number }) {
  const [resultat, setResultat] = useState<{ id: number; fiche?: FicheDetail; erreur?: string } | null>(null);

  useEffect(() => {
    const controleur = new AbortController();
    getFiche(id, controleur.signal)
      .then((fiche) => setResultat({ id, fiche }))
      .catch((e) => { if (e.name !== 'AbortError') setResultat({ id, erreur: e.message }); });
    return () => controleur.abort();
  }, [id]);

  if (resultat === null || resultat.id !== id) return <p role="status">Chargement de la fiche…</p>;
  if (resultat.erreur || !resultat.fiche) return <p role="alert">{resultat.erreur}</p>;
  const { fiche } = resultat;
  return (
    <p>{fiche.mois} : {fiche.etat}, {fiche.fraisHorsForfait.length} frais hors forfait</p>
  );
}

Le résultat mémorise l'identifiant pour lequel il a été obtenu : si l'utilisateur passe de la fiche 12 à la fiche 13, l'ancien résultat ne correspond plus, et le composant affiche « Chargement de la fiche… » au lieu de montrer un instant la fiche 12 sous l'adresse de la 13. Pour une fiche inexistante, l'API répond 404 avec { "detail": "La fiche 999 n'existe pas." } : le composant affiche « La fiche 999 n'existe pas. ». Lucas obtient la même réponse pour la fiche 13, qui existe mais appartient à Sarah : l'API ne révèle pas qu'elle existe.

Tu avais trouvé ?

Exercice 3 sur 4 La situation de compétition Réussi À revoir

Un étudiant a écrit l'effet suivant, sans annulation. Décris une suite d'actions de l'utilisateur qui affiche des données fausses, puis explique en une phrase pourquoi l'annulation la corrige.

tsx
useEffect(() => {
  getFiches(etat).then(setFiches);
}, [etat]);
Voir la réponseMasquer la réponse de l'exercice 3

L'utilisateur choisit « Clôturées » (requête A), puis une demi-seconde plus tard « Validées » (requête B). Le serveur répond à B en 100 ms et à A en 800 ms : B affiche les fiches validées, puis A arrive et les remplace par les fiches clôturées, alors que le filtre indique toujours « Validées ».

Avec l'annulation, le changement de filtre exécute le nettoyage de l'effet précédent, qui annule A : sa promesse est rejetée avec AbortError, ignorée, et seule la réponse de B met à jour l'écran.

Tu avais trouvé ?

Exercice 4 sur 4 Compléter le module de service Réussi À revoir

Le contrat prévoit la clôture d'une fiche : PATCH /v1/fiches/{id} avec le corps {"etat":"CL"}, qui répond 200 avec la fiche détaillée. Écris la fonction cloturerFiche dans src/api/fiches.ts, puis le gestionnaire de clic d'un bouton « Clôturer » qui l'appelle et affiche « Fiche clôturée » ou le message d'erreur de l'API.

Voir la réponseMasquer la réponse de l'exercice 4
ts
export function cloturerFiche(id: number) {
  return api<FicheDetail>(`/fiches/${id}`, { method: 'PATCH', corps: { etat: 'CL' } });
}
tsx
const [message, setMessage] = useState<string | null>(null);

async function cloturer() {
  try {
    await cloturerFiche(fiche.id);
    setMessage('Fiche clôturée');
  } catch (e) {
    setMessage(e instanceof ApiError ? e.message : 'Le serveur ne répond pas, réessaie.');
  }
}

<button type="button" onClick={cloturer}>Clôturer</button>
{message && <p role="status">{message}</p>}

L'appel est dans le gestionnaire de clic, pas dans un effet : c'est l'utilisateur qui déclenche l'écriture. Le composant n'écrit ni l'URL ni les en-têtes, jeton compris : le module de service s'en charge. Si la fiche est déjà clôturée, l'API répond 409 et le message affiché est son detail (« Une fiche à l'état CL ne peut pas passer à l'état CL. »).

Tu avais trouvé ?

Chapitre terminé !

Fais les 4 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Formulaires React : champs contrôlés, Actions et useActionStateRevoir le sommaire du cours