Skip to Content

Chapitre 11.1 — Spécificités React

⏱️ TL;DR — En React, le HTML devient du JSX : quelques attributs changent de nom (forhtmlFor, classclassName), 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 via aria-describedby) avec useId().
  • 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-a11y et 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 :

HTMLJSXPourquoi
forhtmlForfor est un mot-clé JS (boucle).
classclassNameclass 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>, un aria-describedby ou un aria-labelledby dans un composant réutilisable, pense useId(). Un id en 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-a11y

Avec 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-a11y valide la forme, pas l’expérience. Un alt="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é : remplacer for par htmlFor, et générer les id avec useId() 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 : classclassName, forhtmlFor. 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 ?

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 ?

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> ?

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 ?

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.

Last updated on