Chapitre 11.1 — Spécificités React
⏱️ TL;DR — En React, le HTML devient du JSX : quelques attributs changent de nom (
for→htmlFor,class→className), mais la sémantique, elle, ne change pas. Ce qui change vraiment, ce sont les outils que React te donne pour faire de l’a11y proprement :useId()pour générer des identifiants stables et lier label/champ/aria, les Fragments (<>) pour éviter la soupe de<div>, les refs pour piloter le focus. Et un garde-fou à installer d’urgence :eslint-plugin-jsx-a11y. Un préalable : non, React ne « gère » pas l’accessibilité à ta place.
🎯 Objectifs
- Traduire un HTML accessible en JSX sans perdre la sémantique.
- Lier un
<label>à son champ (et un message d’erreur viaaria-describedby) avecuseId(). - Utiliser les Fragments pour ne pas polluer le DOM de
<div>inutiles. - Savoir à quoi servent les refs pour l’a11y (piloter le focus).
- Installer
eslint-plugin-jsx-a11yet connaître ce qu’il attrape… et ce qu’il rate.
Le mythe « React gère l’a11y »
Posons-le d’entrée : React ne rend rien accessible tout seul. React est une bibliothèque de rendu ; elle transforme ton JSX en DOM, fidèlement. Si tu écris une <div onClick>, tu obtiens une <div> inaccessible — exactement comme en HTML pur (Chapitre 4.1). React n’ajoute ni rôle, ni focus, ni annonce.
Ce que React apporte, c’est de la plomberie : des identifiants stables (useId), des Fragments, des refs, un écosystème de linting et de librairies accessibles. Les principes du cours restent intégralement valables : bon élément natif d’abord, ARIA en dernier recours, tout au clavier, contrastes suffisants. React ne te dispense de rien — il te donne juste de meilleurs outils pour le faire bien.
JSX n’est pas HTML : les attributs qui changent
JSX ressemble à du HTML, mais c’est du JavaScript. Certains attributs entrent en collision avec des mots réservés et changent de nom. Les deux à connaître par cœur :
| HTML | JSX | Pourquoi |
|---|---|---|
for | htmlFor | for est un mot-clé JS (boucle). |
class | className | class est un mot-clé JS (classes). |
C’est le piège a11y du débutant React : écrire <label for="email"> ne lie rien en JSX. Le for est ignoré, le label flotte, le champ n’a plus de nom accessible.
// ❌ En JSX, `for` ne lie pas le label au champ : nom accessible manquant
<label for="email">E-mail</label>
<input id="email" type="email" />// ✅ `htmlFor` fait le lien : le champ est annoncé « E-mail, zone de saisie »
<label htmlFor="email">E-mail</label>
<input id="email" type="email" />Le reste des attributs a11y garde son nom HTML : aria-label, aria-describedby, role, alt, lang, tabIndex (attention, celui-ci prend un I majuscule en JSX). Dans le doute, l’ESLint plus bas te préviendra.
useId() : des identifiants stables pour lier les éléments
Lier un label à un champ demande un id unique. En dur (id="email"), ça casse dès que le composant est rendu deux fois sur la page (deux champs « email »). La tentation du Math.random() est pire : l’id change à chaque rendu, et l’association se rompt — sans compter les erreurs d’hydratation (Chapitre 11.5).
Le hook useId() (React 18+) résout ça : il génère un identifiant stable et unique, identique côté serveur et côté client.
import { useId } from "react";
function ChampEmail() {
const id = useId();
const aideId = `${id}-aide`;
return (
<div>
<label htmlFor={id}>E-mail</label>
<input
id={id}
type="email"
aria-describedby={aideId}
/>
<p id={aideId}>On ne partage jamais ton adresse.</p>
</div>
);
}Ici, un seul useId() sert de base : on en dérive l’id du champ et celui du texte d’aide. Rends ce composant dix fois : dix associations correctes, zéro collision. useId est fait pour lier des éléments (label, aria-describedby, aria-labelledby), pas pour servir de key de liste.
💡 Réflexe — Dès que tu écris un
<label>, unaria-describedbyou unaria-labelledbydans un composant réutilisable, penseuseId(). Uniden dur dans un composant est un bug qui n’attend que le deuxième rendu pour exploser.
Les Fragments : éviter la soupe de <div>
React exige qu’un composant retourne un seul nœud racine. Le réflexe paresseux est d’emballer tout dans une <div> — qui s’accumulent et polluent le DOM. Or un DOM boursouflé de <div> non sémantiques brouille l’arbre d’accessibilité et casse des relations de structure (une <div> intruse entre un <ul> et ses <li>, par exemple, rend la liste invalide).
Le Fragment (<>…</>) regroupe des éléments sans produire de nœud dans le DOM.
// ❌ Une <div> injustifiée s'intercale entre <dl> et ses <dt>/<dd>
function Definition() {
return (
<div>
<dt>a11y</dt>
<dd>Accessibilité</dd>
</div>
);
}// ✅ Le Fragment ne rend aucun nœud : la structure <dl> reste valide
function Definition() {
return (
<>
<dt>a11y</dt>
<dd>Accessibilité</dd>
</>
);
}Règle : n’ajoute une <div> que si elle a un rôle (mise en page, cible de style, conteneur ARIA). Sinon, Fragment.
Les refs : ton accès au focus
En HTML/JS classique, tu fais document.querySelector(...).focus(). En React, tu ne touches pas le DOM directement : tu passes par une ref (useRef) attachée à l’élément, puis ref.current.focus(). C’est la clé de toute la gestion du focus — déplacer le focus après une navigation, ouvrir une modale, signaler une erreur. On y consacre le Chapitre 11.3 ; retiens pour l’instant que la ref est ton curseur programmatique.
import { useRef } from "react";
function Formulaire() {
const champRef = useRef(null);
return (
<form onSubmit={() => champRef.current?.focus()}>
<input ref={champRef} aria-label="Nom" />
<button type="submit">Valider</button>
</form>
);
}eslint-plugin-jsx-a11y : le garde-fou à installer
C’est le linter a11y de référence pour React. Il analyse ton JSX au fil de la frappe et signale des dizaines de fautes classiques.
npm install --save-dev eslint-plugin-jsx-a11yAvec un eslint.config.js moderne (flat config) :
import jsxA11y from "eslint-plugin-jsx-a11y";
export default [
jsxA11y.flatConfigs.recommended,
// …ta config
];Ce qu’il attrape : alt manquant sur <img>, <a> sans contenu, onClick sur un élément non interactif sans rôle/clavier, htmlFor orphelin, aria-* mal orthographié ou invalide, tabIndex positif, autoFocus, etc.
Ce qu’il ne peut pas attraper : c’est de l’analyse statique. Il ne sait pas si ton alt est pertinent (alt="image" passe), si l’ordre de focus est logique à l’exécution, si le contraste réel est suffisant, ou si ta modale piège bien le focus. Le linter élimine les fautes grossières ; il ne remplace jamais un test au clavier et au lecteur d’écran (Partie 14).
⚠️ Piège — Prendre un ESLint « tout vert » pour une preuve d’accessibilité.
eslint-plugin-jsx-a11yvalide la forme, pas l’expérience. Unalt="photo", un focus qui saute dans le vide ou un composant custom entièrement fait maison peuvent passer le lint et rester inutilisables. Le linter est un filet, pas un audit.
🧭 Sur A11yLearn — Le formulaire d’inscription d’A11yLearn a d’abord été porté en React sans changer les habitudes HTML :
<label for="...">partout. Résultat, en JSX, aucun champ n’était lié à son label — le lecteur d’écran annonçait « zone de saisie » sans nom. Deux corrections ont tout réglé : remplacerforparhtmlFor, et générer lesidavecuseId()pour que le champ « E-mail » du header et celui du formulaire ne se marchent plus dessus.eslint-plugin-jsx-a11y, activé dans la foulée, a signalé les derniers labels orphelins avant même le commit.
✏️ Exercices
Exercice 1 — Traduis en JSX accessible. Ce composant a été copié-collé depuis du HTML. Corrige-le pour qu’il soit valide et accessible en React.
function Recherche() {
return (
<div class="recherche">
<label for="q">Rechercher un cours</label>
<input id="q" type="search" />
</div>
);
}✅ Solution
Deux attributs à renommer : class → className, for → htmlFor. Mieux : générer l’id avec useId() pour que le composant soit réutilisable sans collision.
import { useId } from "react";
function Recherche() {
const id = useId();
return (
<div className="recherche">
<label htmlFor={id}>Rechercher un cours</label>
<input id={id} type="search" />
</div>
);
}Exercice 2 — Pourquoi ça casse au deuxième rendu ? Un collègue lie son champ avec un id en dur (id="tel"). Sur la page « Contact », il y a deux formulaires (un en header, un en pied de page). Quel est le bug a11y, et comment le corriger ?
✅ Solution
Deux champs partagent id="tel" : les id ne sont plus uniques. Un <label htmlFor="tel"> peut alors pointer vers le mauvais champ (le premier trouvé), et le lien label↔champ devient imprévisible pour le lecteur d’écran. Correctif : générer l’id avec useId() dans le composant de champ, pour que chaque instance ait le sien. C’est exactement le cas d’usage de useId.
🧠 Quiz de révision
1. Pourquoi <label for="email"> ne fonctionne-t-il pas en JSX ?
<label for="email"> ne fonctionne-t-il pas en JSX ?Parce que for est un mot-clé JavaScript ; en JSX il faut écrire htmlFor. Avec for, l’attribut est ignoré, le label n’est plus associé au champ, et le champ perd son nom accessible. De même, class devient className.
2. À quoi sert useId() et à quoi ne sert-il PAS ?
useId() et à quoi ne sert-il PAS ?Il génère un identifiant stable et unique (identique serveur/client) pour lier des éléments : htmlFor, aria-describedby, aria-labelledby. Il ne sert pas de key de liste. Il évite les collisions d’id quand un composant est rendu plusieurs fois.
3. Quand faut-il un Fragment plutôt qu’une <div> ?
<div> ?Quand tu dois retourner plusieurs éléments sans ajouter de nœud au DOM. Le Fragment (<>…</>) évite la « soupe de <div> » qui alourdit l’arbre d’accessibilité et peut casser des structures (<ul>/<li>, <dl>/<dt>). On ne garde une <div> que si elle a un rôle réel.
4. « React gère l’accessibilité. » Vrai ou faux ?
Faux. React génère fidèlement le DOM que tu décris : une <div onClick> reste inaccessible. React fournit des outils (useId, refs, Fragments) et un écosystème, mais tous les principes du cours (élément natif, clavier, ARIA en dernier) restent à ta charge.
5. Que ne peut PAS attraper eslint-plugin-jsx-a11y ?
eslint-plugin-jsx-a11y ?Tout ce qui relève de l’exécution ou du sens : la pertinence d’un alt, l’ordre de focus réel, le contraste effectif, le bon fonctionnement d’un focus trap. C’est de l’analyse statique : elle élimine les fautes de forme, mais ne remplace pas un test clavier + lecteur d’écran.
Chapitre suivant : Le problème SPA & le routing — pourquoi une navigation client est muette pour le lecteur d’écran, et comment l’annoncer.