Skip to Content

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

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