Cours JavaScript et React · Module 9: Hooks React modernes
useId - identifiants uniques pour l'accessibilité
Dans cette leçon5
À bord, chaque interrupteur porte une plaque descriptive. Si deux interrupteurs reçoivent la même plaque, l'astronaute tirera le mauvais. En HTML, ce rôle est joué par le lien entre <label htmlFor> et l'id du champ, et les lecteurs d'écran s'appuient entièrement dessus. Le problème commence quand le même composant de formulaire apparaît trois fois à l'écran : où trouver trois identifiants différents et prévisibles ?
Le hook useId est un outil simple mais extrêmement important introduit dans React 18. Il génère des identifiants uniques qui sont stables à la fois côté serveur et côté client, ce qui est essentiel pour l'accessibilité et un rendu SSR correct.
Les bases de useId
1. Le problème que résout useId
Commençons par deux idées populaires mais fausses, puis comparons-les avec useId :
1// MAUVAIS - ID instable
2function BadExample() {
3 // Math.random() donnera des valeurs différentes sur le serveur et le client !
4 const id = Math.random().toString(36).substr(2, 9);
5
6 return (
7 <div>
8 <label htmlFor={id}>Email :</label>
9 <input id={id} type="email" />
10 </div>
11 );
12}
13
14// MAUVAIS - compteurs globaux
15let counter = 0;
16function AnotherBadExample() {
17 // Le compteur peut différer entre les rendus
18 const id = `input-${counter++}`;
19
20 return (
21 <div>
22 <label htmlFor={id}>Mot de passe :</label>
23 <input id={id} type="password" />
24 </div>
25 );
26}
27
28// BON - useId
29import { useId } from 'react';
30
31function GoodExample() {
32 const id = useId();
33
34 return (
35 <div>
36 <label htmlFor={id}>Nom :</label>
37 <input id={id} type="text" />
38 </div>
39 );
40}Math.random() donne une valeur différente sur le serveur et dans le navigateur, donc l'hydratation signale une incohérence. Un compteur global dépend de l'ordre de rendu, qui peut varier avec le SSR et Suspense. useId s'appuie sur la position du composant dans l'arbre, donc le serveur et le client calculent le même identifiant.
2. Utilisation de base pour des éléments uniques
Le scénario le plus courant est un composant de champ qui relie lui-même son label à son input. Chaque instance appelle le hook séparément :
1import { useId } from 'react';
2
3function AccessibleInput({ label, type = 'text', ...props }) {
4 const id = useId();
5
6 return (
7 <div style={{ marginBottom: '15px' }}>
8 <label
9 htmlFor={id}
10 style={{
11 display: 'block',
12 marginBottom: '5px',
13 fontWeight: 'bold'
14 }}
15 >
16 {label}
17 </label>
18 <input
19 id={id}
20 type={type}
21 style={{
22 width: '100%',
23 padding: '8px',
24 border: '1px solid #ddd',
25 borderRadius: '4px'
26 }}
27 {...props}
28 />
29 </div>
30 );
31}
32
33// Utilisation du composant
34function RegistrationForm() {
35 return (
36 <form style={{ maxWidth: '400px', margin: '0 auto' }}>
37 <h2>Inscription</h2>
38 <AccessibleInput label="Prénom :" />
39 <AccessibleInput label="Nom :" />
40 <AccessibleInput label="Email :" type="email" />
41 <AccessibleInput label="Mot de passe :" type="password" />
42 <button type="submit">S'inscrire</button>
43 </form>
44 );
45}Les quatre champs AccessibleInput reçoivent quatre identifiants différents, alors qu'ils partagent le même code. Cliquer sur un label place le focus dans le champ, et un lecteur d'écran annonce son nom.
3. Utilisation avec plusieurs éléments liés
Quand tu as besoin de nombreux identifiants, n'appelle pas le hook dans une boucle. Appelle-le une fois et ajoute des suffixes :
1import { useId } from 'react';
2
3function RadioGroup({ label, options, name, onChange }) {
4 const baseId = useId();
5
6 return (
7 <fieldset style={{ border: '1px solid #ddd', borderRadius: '8px', padding: '15px' }}>
8 <legend style={{ fontWeight: 'bold', padding: '0 10px' }}>{label}</legend>
9 {options.map((option, index) => {
10 const optionId = `${baseId}-${index}`;
11
12 return (
13 <div key={option.value} style={{ marginBottom: '10px' }}>
14 <input
15 type="radio"
16 id={optionId}
17 name={name}
18 value={option.value}
19 onChange={onChange}
20 style={{ marginRight: '8px' }}
21 />
22 <label htmlFor={optionId} style={{ cursor: 'pointer' }}>
23 {option.label}
24 </label>
25 </div>
26 );
27 })}
28 </fieldset>
29 );
30}
31
32// Utilisation
33function PreferencesForm() {
34 const handleThemeChange = (e) => console.log('Thème :', e.target.value);
35 const handleLanguageChange = (e) => console.log('Langue :', e.target.value);
36
37 return (
38 <div style={{ maxWidth: '500px', margin: '0 auto' }}>
39 <h2>Préférences</h2>
40
41 <RadioGroup
42 label="Choisis un thème :"
43 name="theme"
44 onChange={handleThemeChange}
45 options={[
46 { value: 'light', label: 'Clair' },
47 { value: 'dark', label: 'Sombre' },
48 { value: 'auto', label: 'Automatique' }
49 ]}
50 />
51
52 <div style={{ marginTop: '20px' }}>
53 <RadioGroup
54 label="Langue de l'interface :"
55 name="language"
56 onChange={handleLanguageChange}
57 options={[
58 { value: 'pl', label: 'Polonais' },
59 { value: 'en', label: 'Anglais' },
60 { value: 'de', label: 'Allemand' }
61 ]}
62 />
63 </div>
64 </div>
65 );
66}Chaque groupe radio a son propre baseId, donc les options du groupe « thème » n'entrent jamais en collision avec celles du groupe « langue ». Remarque que les clés de la liste viennent toujours des données, option.value, et non de useId.
Patterns avancés avec useId
1. Composants avec descriptions ARIA
L'attribut aria-describedby pointe vers les identifiants des éléments contenant un texte d'aide ou une erreur, et aria-invalid indique que la valeur est incorrecte. D'abord, le composant de champ :
1import { useId } from 'react';
2
3function AccessibleTextField({
4 label,
5 helperText,
6 errorText,
7 required = false,
8 ...props
9}) {
10 const inputId = useId();
11 const helperId = useId();
12 const errorId = useId();
13
14 const ariaDescribedBy = [
15 helperText && helperId,
16 errorText && errorId
17 ].filter(Boolean).join(' ');
18
19 return (
20 <div style={{ marginBottom: '20px' }}>
21 <label
22 htmlFor={inputId}
23 style={{
24 display: 'block',
25 marginBottom: '5px',
26 fontWeight: 'bold'
27 }}
28 >
29 {label}
30 {required && <span style={{ color: 'red' }}> *</span>}
31 </label>
32
33 <input
34 id={inputId}
35 aria-describedby={ariaDescribedBy || undefined}
36 aria-invalid={!!errorText}
37 aria-required={required}
38 style={{
39 width: '100%',
40 padding: '10px',
41 border: `2px solid ${errorText ? '#dc3545' : '#ddd'}`,
42 borderRadius: '4px',
43 fontSize: '16px'
44 }}
45 {...props}
46 />
47
48 {helperText && !errorText && (
49 <div
50 id={helperId}
51 style={{
52 marginTop: '5px',
53 fontSize: '14px',
54 color: '#666'
55 }}
56 >
57 {helperText}
58 </div>
59 )}
60
61 {errorText && (
62 <div
63 id={errorId}
64 role="alert"
65 style={{
66 marginTop: '5px',
67 fontSize: '14px',
68 color: '#dc3545'
69 }}
70 >
71 {errorText}
72 </div>
73 )}
74 </div>
75 );
76}
77La description et l'erreur ne sont rattachées au champ que si elles existent, et role="alert" fait annoncer l'erreur immédiatement par le lecteur d'écran. Voici maintenant le formulaire qui l'utilise :
1// Exemple d'utilisation avec validation
2function ContactForm() {
3 const [email, setEmail] = useState('');
4 const [phone, setPhone] = useState('');
5 const [errors, setErrors] = useState({});
6
7 const validateEmail = (value) => {
8 if (!value) return 'L’email est requis';
9 if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
10 return 'Format d’email invalide';
11 }
12 return '';
13 };
14
15 const validatePhone = (value) => {
16 if (value && !/^\d{9}$/.test(value.replace(/\s/g, ''))) {
17 return 'Le numéro de téléphone doit comporter 9 chiffres';
18 }
19 return '';
20 };
21
22 const handleEmailChange = (e) => {
23 const value = e.target.value;
24 setEmail(value);
25 setErrors(prev => ({ ...prev, email: validateEmail(value) }));
26 };
27
28 const handlePhoneChange = (e) => {
29 const value = e.target.value;
30 setPhone(value);
31 setErrors(prev => ({ ...prev, phone: validatePhone(value) }));
32 };
33
34 return (
35 <form style={{ maxWidth: '500px', margin: '0 auto' }}>
36 <h2>Formulaire de contact</h2>
37
38 <AccessibleTextField
39 label="Adresse email"
40 type="email"
41 value={email}
42 onChange={handleEmailChange}
43 helperText="Nous utiliserons cette adresse uniquement pour te contacter au sujet de ta demande"
44 errorText={errors.email}
45 required
46 />
47
48 <AccessibleTextField
49 label="Numéro de téléphone"
50 type="tel"
51 value={phone}
52 onChange={handlePhoneChange}
53 helperText="Facultatif - accélère le contact"
54 errorText={errors.phone}
55 />
56
57 <button
58 type="submit"
59 disabled={!!errors.email || !!errors.phone}
60 style={{
61 padding: '10px 20px',
62 backgroundColor: '#007bff',
63 color: 'white',
64 border: 'none',
65 borderRadius: '4px',
66 cursor: 'pointer',
67 fontSize: '16px'
68 }}
69 >
70 Envoyer
71 </button>
72 </form>
73 );
74}Le formulaire ne connaît aucun identifiant, car c'est AccessibleTextField qui les gère en interne.
2. Composants accordéon/repli
Le pattern d'accordéon des recommandations WAI-ARIA exige de relier le bouton au panneau via aria-controls et aria-labelledby. Deux appels à useId dans AccordionItem suffisent :
1import { useId, useState } from 'react';
2
3function Accordion({ items }) {
4 const [openIndex, setOpenIndex] = useState(null);
5
6 return (
7 <div style={{ border: '1px solid #ddd', borderRadius: '8px', overflow: 'hidden' }}>
8 {items.map((item, index) => (
9 <AccordionItem
10 key={index}
11 title={item.title}
12 content={item.content}
13 isOpen={openIndex === index}
14 onToggle={() => setOpenIndex(openIndex === index ? null : index)}
15 />
16 ))}
17 </div>
18 );
19}
20
21function AccordionItem({ title, content, isOpen, onToggle }) {
22 const headerId = useId();
23 const panelId = useId();
24
25 return (
26 <div style={{ borderBottom: '1px solid #ddd' }}>
27 <h3 style={{ margin: 0 }}>
28 <button
29 id={headerId}
30 aria-expanded={isOpen}
31 aria-controls={panelId}
32 onClick={onToggle}
33 style={{
34 width: '100%',
35 padding: '15px 20px',
36 border: 'none',
37 background: isOpen ? '#f8f9fa' : 'white',
38 textAlign: 'left',
39 fontSize: '16px',
40 cursor: 'pointer',
41 display: 'flex',
42 justifyContent: 'space-between',
43 alignItems: 'center'
44 }}
45 >
46 <span>{title}</span>
47 <span style={{
48 transform: isOpen ? 'rotate(180deg)' : 'rotate(0)',
49 transition: 'transform 0.3s'
50 }}>
51 ▼
52 </span>
53 </button>
54 </h3>
55
56 <div
57 id={panelId}
58 role="region"
59 aria-labelledby={headerId}
60 hidden={!isOpen}
61 style={{
62 padding: isOpen ? '20px' : '0 20px',
63 maxHeight: isOpen ? '500px' : '0',
64 overflow: 'hidden',
65 transition: 'all 0.3s ease-in-out',
66 backgroundColor: '#f8f9fa'
67 }}
68 >
69 {content}
70 </div>
71 </div>
72 );
73}
74
75// Utilisation
76function FAQSection() {
77 const faqItems = [
78 {
79 title: 'Qu’est-ce que useId ?',
80 content: 'useId est un hook React qui génère des identifiants uniques stables entre le serveur et le client. Il est particulièrement utile pour l’accessibilité.'
81 },
82 {
83 title: 'Quand utiliser useId ?',
84 content: 'Utilise useId chaque fois que tu as besoin d’identifiants uniques pour des éléments HTML, notamment pour les associations label-input, les attributs ARIA ou d’autres relations entre éléments.'
85 },
86 {
87 title: 'useId est-il sûr pour le SSR ?',
88 content: 'Oui ! useId a été spécialement conçu pour générer les mêmes identifiants sur le serveur et le client, éliminant les problèmes d’hydratation.'
89 }
90 ];
91
92 return (
93 <div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
94 <h2>Questions fréquentes</h2>
95 <Accordion items={faqItems} />
96 </div>
97 );
98}L'attribut aria-expanded indique si le panneau est ouvert, et hidden cache le contenu fermé aux lecteurs d'écran aussi.
3. Formulaires avancés avec validation
Le champ de mot de passe a trois éléments liés : l'input, l'indicateur de force et la liste des exigences. D'abord, la logique d'évaluation du mot de passe :
1import { useId, useState } from 'react';
2
3function PasswordField({
4 label = "Mot de passe",
5 value,
6 onChange,
7 showStrength = true
8}) {
9 const [showPassword, setShowPassword] = useState(false);
10 const inputId = useId();
11 const strengthId = useId();
12 const requirementsId = useId();
13
14 const calculateStrength = (password) => {
15 let strength = 0;
16 if (password.length >= 8) strength++;
17 if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength++;
18 if (/\d/.test(password)) strength++;
19 if (/[^a-zA-Z0-9]/.test(password)) strength++;
20 return strength;
21 };
22
23 const strength = calculateStrength(value);
24 const strengthLabels = ['Très faible', 'Faible', 'Moyen', 'Fort', 'Très fort'];
25 const strengthColors = ['#dc3545', '#fd7e14', '#ffc107', '#28a745', '#20c997'];
26
27 const requirements = [
28 { met: value.length >= 8, text: 'Minimum 8 caractères' },
29 { met: /[a-z]/.test(value) && /[A-Z]/.test(value), text: 'Lettres minuscules et majuscules' },
30 { met: /\d/.test(value), text: 'Au moins un chiffre' },
31 { met: /[^a-zA-Z0-9]/.test(value), text: 'Au moins un caractère spécial' }
32 ];
33La force est le nombre de conditions remplies, de 0 à 4, ce qui correspond aux cinq libellés de strengthLabels. Voici la vue du champ :
1 return (
2 <div style={{ marginBottom: '20px' }}>
3 <label
4 htmlFor={inputId}
5 style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}
6 >
7 {label}
8 </label>
9
10 <div style={{ position: 'relative' }}>
11 <input
12 id={inputId}
13 type={showPassword ? 'text' : 'password'}
14 value={value}
15 onChange={onChange}
16 aria-describedby={`${strengthId} ${requirementsId}`}
17 style={{
18 width: '100%',
19 padding: '10px 40px 10px 10px',
20 border: '2px solid #ddd',
21 borderRadius: '4px',
22 fontSize: '16px'
23 }}
24 />
25
26 <button
27 type="button"
28 onClick={() => setShowPassword(!showPassword)}
29 aria-label={showPassword ? 'Masquer le mot de passe' : 'Afficher le mot de passe'}
30 style={{
31 position: 'absolute',
32 right: '10px',
33 top: '50%',
34 transform: 'translateY(-50%)',
35 background: 'none',
36 border: 'none',
37 cursor: 'pointer',
38 fontSize: '20px'
39 }}
40 >
41 {showPassword ? 'Masquer' : 'Afficher'}
42 </button>
43 </div>
44
45 {showStrength && value && (
46 <>
47 <div
48 id={strengthId}
49 style={{ marginTop: '10px' }}
50 role="status"
51 aria-live="polite"
52 >
53 <div style={{
54 display: 'flex',
55 justifyContent: 'space-between',
56 marginBottom: '5px',
57 fontSize: '14px'
58 }}>
59 <span>Force du mot de passe :</span>
60 <span style={{ color: strengthColors[strength], fontWeight: 'bold' }}>
61 {strengthLabels[strength]}
62 </span>
63 </div>
64
65 <div style={{
66 height: '4px',
67 backgroundColor: '#e9ecef',
68 borderRadius: '2px',
69 overflow: 'hidden'
70 }}>
71 <div style={{
72 width: `${(strength + 1) * 20}%`,
73 height: '100%',
74 backgroundColor: strengthColors[strength],
75 transition: 'all 0.3s ease'
76 }} />
77 </div>
78 </div>
79
80 <ul
81 id={requirementsId}
82 style={{
83 marginTop: '10px',
84 paddingLeft: '20px',
85 fontSize: '14px'
86 }}
87 >
88 {requirements.map((req, index) => (
89 <li
90 key={index}
91 style={{
92 color: req.met ? '#28a745' : '#6c757d',
93 listStyleType: 'none'
94 }}
95 >
96 {req.met ? '√ ' : '○ '}{req.text}
97 </li>
98 ))}
99 </ul>
100 </>
101 )}
102 </div>
103 );
104}
105Grâce à aria-describedby, l'input pointe vers les deux identifiants à la fois, et aria-live="polite" annonce les changements de force sans interrompre l'utilisateur. On assemble le tout dans un formulaire d'inscription :
1// Formulaire d'inscription complet
2function SecureRegistrationForm() {
3 const [formData, setFormData] = useState({
4 username: '',
5 email: '',
6 password: '',
7 confirmPassword: ''
8 });
9
10 const [agreed, setAgreed] = useState(false);
11 const checkboxId = useId();
12
13 const handleSubmit = (e) => {
14 e.preventDefault();
15 console.log('Formulaire soumis :', formData);
16 };
17
18 const passwordsMatch = formData.password === formData.confirmPassword;
19
20 return (
21 <form
22 onSubmit={handleSubmit}
23 style={{ maxWidth: '500px', margin: '0 auto', padding: '20px' }}
24 >
25 <h2>Inscription sécurisée</h2>
26
27 <AccessibleTextField
28 label="Nom d'utilisateur"
29 value={formData.username}
30 onChange={(e) => setFormData(prev => ({ ...prev, username: e.target.value }))}
31 helperText="3 à 20 caractères, lettres et chiffres uniquement"
32 required
33 />
34
35 <AccessibleTextField
36 label="Email"
37 type="email"
38 value={formData.email}
39 onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
40 required
41 />
42
43 <PasswordField
44 label="Mot de passe"
45 value={formData.password}
46 onChange={(e) => setFormData(prev => ({ ...prev, password: e.target.value }))}
47 />
48
49 <PasswordField
50 label="Confirme le mot de passe"
51 value={formData.confirmPassword}
52 onChange={(e) => setFormData(prev => ({ ...prev, confirmPassword: e.target.value }))}
53 showStrength={false}
54 />
55
56 {formData.confirmPassword && !passwordsMatch && (
57 <div style={{ color: '#dc3545', fontSize: '14px', marginTop: '-15px', marginBottom: '15px' }}>
58 Les mots de passe ne correspondent pas
59 </div>
60 )}
61
62 <div style={{ marginBottom: '20px' }}>
63 <input
64 type="checkbox"
65 id={checkboxId}
66 checked={agreed}
67 onChange={(e) => setAgreed(e.target.checked)}
68 style={{ marginRight: '8px' }}
69 />
70 <label htmlFor={checkboxId} style={{ fontSize: '14px' }}>
71 J'accepte les <a href="#">conditions d'utilisation</a> et la <a href="#">politique de confidentialité</a>
72 </label>
73 </div>
74
75 <button
76 type="submit"
77 disabled={!agreed || !passwordsMatch || !formData.password}
78 style={{
79 width: '100%',
80 padding: '12px',
81 backgroundColor: '#007bff',
82 color: 'white',
83 border: 'none',
84 borderRadius: '4px',
85 fontSize: '16px',
86 cursor: 'pointer',
87 opacity: (!agreed || !passwordsMatch || !formData.password) ? 0.6 : 1
88 }}
89 >
90 S'inscrire
91 </button>
92 </form>
93 );
94}La case de consentement reçoit son propre useId directement dans le formulaire, car ce n'est pas un composant séparé.
Applications pratiques de useId
1. Composants réutilisables
Un interrupteur et une notation par étoiles sont des composants classiques de bibliothèque UI, qui peuvent apparaître plusieurs fois sur une page :
1import { useId, Fragment } from 'react';
2
3// Composant Toggle/Switch universel
4function Toggle({ label, checked, onChange, disabled = false }) {
5 const id = useId();
6
7 return (
8 <div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
9 <input
10 type="checkbox"
11 id={id}
12 checked={checked}
13 onChange={onChange}
14 disabled={disabled}
15 style={{ display: 'none' }}
16 />
17 <label
18 htmlFor={id}
19 style={{
20 position: 'relative',
21 width: '50px',
22 height: '24px',
23 backgroundColor: checked ? '#007bff' : '#ccc',
24 borderRadius: '12px',
25 cursor: disabled ? 'not-allowed' : 'pointer',
26 opacity: disabled ? 0.6 : 1,
27 transition: 'background-color 0.3s'
28 }}
29 >
30 <span
31 style={{
32 position: 'absolute',
33 top: '2px',
34 left: checked ? '26px' : '2px',
35 width: '20px',
36 height: '20px',
37 backgroundColor: 'white',
38 borderRadius: '50%',
39 transition: 'left 0.3s',
40 boxShadow: '0 2px 4px rgba(0,0,0,0.2)'
41 }}
42 />
43 </label>
44 <span style={{ fontSize: '14px' }}>{label}</span>
45 </div>
46 );
47}
48
49// Composant de notation
50function StarRating({ label, value, onChange, max = 5 }) {
51 const baseId = useId();
52
53 return (
54 <div>
55 <div style={{ marginBottom: '5px', fontWeight: 'bold' }}>
56 {label}
57 </div>
58 <div style={{ display: 'flex', gap: '5px' }}>
59 {Array.from({ length: max }, (_, i) => {
60 const ratingValue = i + 1;
61 const inputId = `${baseId}-${ratingValue}`;
62
63 return (
64 <Fragment key={ratingValue}>
65 <input
66 type="radio"
67 id={inputId}
68 name={`rating-${baseId}`}
69 value={ratingValue}
70 checked={value === ratingValue}
71 onChange={() => onChange(ratingValue)}
72 style={{ display: 'none' }}
73 />
74 <label
75 htmlFor={inputId}
76 style={{
77 fontSize: '24px',
78 cursor: 'pointer',
79 color: ratingValue <= value ? '#ffc107' : '#ddd'
80 }}
81 >
82 ◆
83 </label>
84 </Fragment>
85 );
86 })}
87 </div>
88 </div>
89 );
90}Attention à un détail : display: 'none' retire l'input de l'arbre d'accessibilité et de la navigation au clavier. En production, mieux vaut le cacher visuellement avec une classe de type « visually hidden », pour que l'interrupteur reste accessible avec la touche Tab.
2. Composants avec infobulles
Une infobulle doit être reliée à son bouton via aria-describedby et apparaître aussi au focus, pas seulement au survol de la souris :
1import { useId, useState } from 'react';
2
3function TooltipButton({ buttonText, tooltipText }) {
4 const [showTooltip, setShowTooltip] = useState(false);
5 const tooltipId = useId();
6
7 return (
8 <div style={{ position: 'relative', display: 'inline-block' }}>
9 <button
10 aria-describedby={showTooltip ? tooltipId : undefined}
11 onMouseEnter={() => setShowTooltip(true)}
12 onMouseLeave={() => setShowTooltip(false)}
13 onFocus={() => setShowTooltip(true)}
14 onBlur={() => setShowTooltip(false)}
15 style={{
16 padding: '10px 20px',
17 backgroundColor: '#007bff',
18 color: 'white',
19 border: 'none',
20 borderRadius: '4px',
21 cursor: 'pointer'
22 }}
23 >
24 {buttonText}
25 </button>
26
27 {showTooltip && (
28 <div
29 id={tooltipId}
30 role="tooltip"
31 style={{
32 position: 'absolute',
33 bottom: '100%',
34 left: '50%',
35 transform: 'translateX(-50%)',
36 marginBottom: '10px',
37 padding: '8px 12px',
38 backgroundColor: 'rgba(0,0,0,0.8)',
39 color: 'white',
40 borderRadius: '4px',
41 fontSize: '14px',
42 whiteSpace: 'nowrap',
43 zIndex: 1000
44 }}
45 >
46 {tooltipText}
47 <div style={{
48 position: 'absolute',
49 top: '100%',
50 left: '50%',
51 transform: 'translateX(-50%)',
52 width: 0,
53 height: 0,
54 borderLeft: '5px solid transparent',
55 borderRight: '5px solid transparent',
56 borderTop: '5px solid rgba(0,0,0,0.8)'
57 }} />
58 </div>
59 )}
60 </div>
61 );
62}Le rôle tooltip et la gestion de onFocus et onBlur donnent à l'utilisateur du clavier la même information qu'à l'utilisateur de la souris.
Bonnes pratiques avec useId
1. Quand utiliser useId
Les exemples ci-dessous rassemblent les bons et les mauvais usages :
1// BON - éléments de formulaire
2function GoodForm() {
3 const emailId = useId();
4 return (
5 <>
6 <label htmlFor={emailId}>Email :</label>
7 <input id={emailId} type="email" />
8 </>
9 );
10}
11
12// BON - relations ARIA
13function GoodAria() {
14 const descriptionId = useId();
15 return (
16 <>
17 <input aria-describedby={descriptionId} />
18 <span id={descriptionId}>Description utile</span>
19 </>
20 );
21}
22
23// MAUVAIS - comme clés dans des listes
24function BadList({ items }) {
25 return items.map(item => {
26 const id = useId(); // Ne pas utiliser dans des boucles !
27 return <li key={id}>{item}</li>;
28 });
29}
30
31// MAUVAIS - pour identifier des données
32function BadData() {
33 const user = {
34 id: useId(), // Pas pour les données !
35 name: 'John'
36 };
37}La documentation react.dev le dit clairement : useId ne sert pas à générer des clés de liste ni des identifiants de données. Les clés doivent venir de tes données, et le hook ne peut de toute façon pas être appelé dans une boucle.
2. Préfixes pour une meilleure organisation
Tu peux envelopper le hook dans ta propre fonction qui ajoute un préfixe :
1import { useId } from 'react';
2
3function createIdGenerator(prefix) {
4 return function useIdWithPrefix() {
5 const id = useId();
6 return `${prefix}-${id}`;
7 };
8}
9
10// Utilisation
11const useFormId = createIdGenerator('form');
12const useModalId = createIdGenerator('modal');
13const useTabId = createIdGenerator('tab');
14
15function MyForm() {
16 const emailId = useFormId();
17 const passwordId = useFormId();
18
19 // Les IDs générés ressembleront à : form-_r_1_, form-_r_2_ (anciennes versions de React : form-:r1:)
20}Le format de l'identifiant lui-même dépend de la version de React, donc ne construis aucune logique dessus. Quand plusieurs applications React tournent sur une même page, utilise l'option officielle identifierPrefix de createRoot ou hydrateRoot.
3. Tester des composants avec useId
Les tests ne doivent pas dépendre des valeurs générées. Cherche les champs par leur label, comme le fait un utilisateur :
1// Les composants utilisant useId sont faciles à tester
2import { render, screen } from '@testing-library/react';
3import userEvent from '@testing-library/user-event';
4
5function LoginForm() {
6 const usernameId = useId();
7 const passwordId = useId();
8
9 return (
10 <form>
11 <label htmlFor={usernameId}>Nom d’utilisateur :</label>
12 <input id={usernameId} type="text" />
13
14 <label htmlFor={passwordId}>Mot de passe :</label>
15 <input id={passwordId} type="password" />
16
17 <button type="submit">Se connecter</button>
18 </form>
19 );
20}
21
22// Test
23test('les champs du formulaire sont correctement étiquetés', async () => {
24 render(<LoginForm />);
25
26 // On peut retrouver les inputs grâce à leurs labels
27 const usernameInput = screen.getByLabelText('Nom d’utilisateur :');
28 const passwordInput = screen.getByLabelText('Mot de passe :');
29
30 await userEvent.type(usernameInput, 'testuser');
31 await userEvent.type(passwordInput, 'password123');
32
33 expect(usernameInput).toHaveValue('testuser');
34 expect(passwordInput).toHaveValue('password123');
35});getByLabelText ne fonctionne que si le label est correctement relié au champ, donc le test vérifie l'accessibilité au passage.
Révision : la mémoïsation avant les exercices
Les exercices qui suivent cette leçon reviennent sur la mémoïsation. useMemo mémorise le résultat d'un calcul, tandis que useCallback mémorise la fonction elle-même, c'est-à-dire sa référence stable entre les rendus. memo saute le rendu d'un composant quand ses props n'ont pas changé :
1import { useMemo, useCallback, memo } from 'react';
2
3const PlanetList = memo(function PlanetList({ planets, onSelect }) {
4 return planets.map(p => (
5 <button key={p.id} onClick={() => onSelect(p.id)}>{p.name}</button>
6 ));
7});
8
9function Observatory({ planets, query }) {
10 // useMemo : RÉSULTAT du filtrage mémorisé
11 const visible = useMemo(
12 () => planets.filter(p => p.name.includes(query)),
13 [planets, query]
14 );
15 // useCallback : FONCTION mémorisée (même référence)
16 const handleSelect = useCallback((id) => console.log('Sélectionné', id), []);
17
18 return <PlanetList planets={visible} onSelect={handleSelect} />;
19}Sans useCallback, chaque nouvelle fonction handleSelect rendrait memo inutile. N'enveloppe pas tout pour autant : comparer les props a aussi un coût, et pour des composants simples, cela peut coûter plus cher que le rendu lui-même.
useId est un hook simple mais puissant qui améliore considérablement l'accessibilité des applications React. Utilise-le partout où tu as besoin d'identifiants uniques et stables pour des éléments HTML.
Mon conseil : donne dès le départ un useId à chaque champ de formulaire de ta bibliothèque de composants. Retiens : useId est la plaque à côté de l'interrupteur, grâce à laquelle chaque membre d'équipage tire le bon.
Code de cette leçon : App.jsx
1import React, { useId, useState } from 'react';
2
3// Démonstration de useId - génération d'identifiants uniques et stables
4// Essentiel pour l'accessibilité et le SSR
5
6// Composant de formulaire avec useId - chaque instance a un ID unique
7function CrewMemberForm({ title }) {
8 // useId génère un prefix unique pour cette instance du composant
9 const id = useId();
10
11 const [name, setName] = useState('');
12 const [role, setRole] = useState('');
13 const [clearance, setClearance] = useState('standard');
14
15 const nameId = id + '-name';
16 const roleId = id + '-role';
17 const clearanceId = id + '-clearance';
18 const nameErrorId = id + '-name-error';
19
20 const nameError = name.length > 0 && name.length < 2 ? 'Le nom doit comporter au moins 2 caractères' : '';
21
22 return (
23 <fieldset style={{ background: '#1b2838', padding: '16px', borderRadius: '12px', border: '1px solid #415a77', margin: 0 }}>
24 <legend style={{ color: '#00d4ff', fontWeight: 'bold', padding: '0 8px' }}>{title}</legend>
25
26 <div style={{ marginBottom: '12px' }}>
27 <label htmlFor={nameId} style={{ display: 'block', color: '#778da9', marginBottom: '4px', fontSize: '13px' }}>
28 Nom du membre d'équipage :
29 </label>
30 <input
31 id={nameId}
32 value={name}
33 onChange={e => setName(e.target.value)}
34 aria-describedby={nameError ? nameErrorId : undefined}
35 aria-invalid={!!nameError}
36 placeholder="ex. Capitaine Nova"
37 style={{
38 width: '100%',
39 padding: '8px 12px',
40 borderRadius: '8px',
41 border: '1px solid ' + (nameError ? '#ff6b6b' : '#415a77'),
42 background: '#0d1b2a',
43 color: '#e0e1dd',
44 boxSizing: 'border-box',
45 }}
46 />
47 {nameError && (
48 <p id={nameErrorId} role="alert" style={{ color: '#ff6b6b', fontSize: '12px', margin: '4px 0 0' }}>
49 {nameError}
50 </p>
51 )}
52 </div>
53
54 <div style={{ marginBottom: '12px' }}>
55 <label htmlFor={roleId} style={{ display: 'block', color: '#778da9', marginBottom: '4px', fontSize: '13px' }}>
56 Poste :
57 </label>
58 <input
59 id={roleId}
60 value={role}
61 onChange={e => setRole(e.target.value)}
62 placeholder="ex. Navigateur"
63 style={{ width: '100%', padding: '8px 12px', borderRadius: '8px', border: '1px solid #415a77', background: '#0d1b2a', color: '#e0e1dd', boxSizing: 'border-box' }}
64 />
65 </div>
66
67 <div>
68 <label htmlFor={clearanceId} style={{ display: 'block', color: '#778da9', marginBottom: '4px', fontSize: '13px' }}>
69 Niveau d'accès :
70 </label>
71 <select
72 id={clearanceId}
73 value={clearance}
74 onChange={e => setClearance(e.target.value)}
75 style={{ width: '100%', padding: '8px 12px', borderRadius: '8px', border: '1px solid #415a77', background: '#0d1b2a', color: '#e0e1dd', boxSizing: 'border-box' }}
76 >
77 <option value="standard">Standard</option>
78 <option value="elevated">Élevé</option>
79 <option value="command">Commandement</option>
80 </select>
81 </div>
82
83 <div style={{ marginTop: '12px', background: '#0d1b2a', padding: '8px 12px', borderRadius: '8px' }}>
84 <p style={{ color: '#778da9', margin: 0, fontSize: '11px' }}>
85 useId prefix: <code style={{ color: '#00d4ff' }}>{id}</code>
86 </p>
87 <p style={{ color: '#778da9', margin: '2px 0 0', fontSize: '11px' }}>
88 IDs: <code style={{ color: '#00ff88' }}>{nameId}</code>, <code style={{ color: '#00ff88' }}>{roleId}</code>, <code style={{ color: '#00ff88' }}>{clearanceId}</code>
89 </p>
90 </div>
91 </fieldset>
92 );
93}
94
95// Composant avec plusieurs groupes de boutons radio - chacun a besoin d'un nom unique
96function MissionPrioritySelector() {
97 const id = useId();
98 const [priority, setPriority] = useState('medium');
99
100 const priorities = [
101 { value: 'low', label: 'Faible', color: '#00ff88' },
102 { value: 'medium', label: 'Moyen', color: '#ffaa00' },
103 { value: 'high', label: 'Élevé', color: '#ff6b6b' },
104 { value: 'critical', label: 'Critique', color: '#ff0044' },
105 ];
106
107 return (
108 <div style={{ background: '#1b2838', padding: '16px', borderRadius: '12px', border: '1px solid #415a77' }}>
109 <h3 style={{ color: '#00d4ff', margin: '0 0 12px', fontSize: '14px' }}>Priorité de la mission</h3>
110 <div role="radiogroup" aria-labelledby={id + '-label'} style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
111 <span id={id + '-label'} className="sr-only" style={{ position: 'absolute', width: '1px', height: '1px', overflow: 'hidden' }}>
112 Choisis la priorité de la mission
113 </span>
114 {priorities.map(p => (
115 <label
116 key={p.value}
117 htmlFor={id + '-' + p.value}
118 style={{
119 display: 'flex',
120 alignItems: 'center',
121 gap: '6px',
122 padding: '8px 14px',
123 borderRadius: '8px',
124 border: '1px solid ' + (priority === p.value ? p.color : '#415a77'),
125 background: priority === p.value ? p.color + '22' : 'transparent',
126 cursor: 'pointer',
127 }}
128 >
129 <input
130 type="radio"
131 id={id + '-' + p.value}
132 name={id + '-priority'}
133 value={p.value}
134 checked={priority === p.value}
135 onChange={() => setPriority(p.value)}
136 style={{ accentColor: p.color }}
137 />
138 <span style={{ color: priority === p.value ? p.color : '#778da9', fontSize: '13px' }}>{p.label}</span>
139 </label>
140 ))}
141 </div>
142 <p style={{ color: '#778da9', fontSize: '11px', margin: '8px 0 0' }}>
143 Nom du groupe radio : <code style={{ color: '#00d4ff' }}>{id}-priority</code> (unique grâce à useId)
144 </p>
145 </div>
146 );
147}
148
149// Application principale - plusieurs instances du même composant
150export default function App() {
151 const [formCount, setFormCount] = useState(2);
152
153 return (
154 <div style={{ background: '#0d1b2a', minHeight: '100vh', padding: '20px', color: '#e0e1dd', fontFamily: 'monospace' }}>
155 <div style={{ maxWidth: '550px', margin: '0 auto' }}>
156 <h1 style={{ color: '#00d4ff', textAlign: 'center', fontSize: '18px' }}>useId - Inscription de l'équipage</h1>
157 <p style={{ color: '#778da9', textAlign: 'center', fontSize: '13px', marginBottom: '20px' }}>
158 Chaque instance de formulaire a un ID unique généré par useId
159 </p>
160
161 <div style={{ display: 'grid', gap: '16px' }}>
162 {Array.from({ length: formCount }, (_, i) => (
163 <CrewMemberForm key={i} title={"Membre d'équipage #" + (i + 1)} />
164 ))}
165
166 <MissionPrioritySelector />
167 </div>
168
169 <div style={{ display: 'flex', gap: '8px', justifyContent: 'center', marginTop: '16px' }}>
170 <button
171 onClick={() => setFormCount(c => c + 1)}
172 style={{ padding: '8px 16px', borderRadius: '8px', border: '1px solid #00d4ff', background: 'transparent', color: '#00d4ff', cursor: 'pointer' }}
173 >
174 + Ajouter un formulaire
175 </button>
176 {formCount > 1 && (
177 <button
178 onClick={() => setFormCount(c => c - 1)}
179 style={{ padding: '8px 16px', borderRadius: '8px', border: '1px solid #ff6b6b', background: 'transparent', color: '#ff6b6b', cursor: 'pointer' }}
180 >
181 - Supprimer le dernier
182 </button>
183 )}
184 </div>
185
186 <div style={{ marginTop: '16px', background: '#1b2838', padding: '12px', borderRadius: '8px', border: '1px solid #415a77' }}>
187 <h4 style={{ color: '#778da9', margin: '0 0 8px', fontSize: '12px' }}>Pourquoi useId plutôt que Math.random() ?</h4>
188 <ul style={{ color: '#e0e1dd', fontSize: '12px', margin: 0, paddingLeft: '20px', lineHeight: '1.8' }}>
189 <li><span style={{ color: '#00ff88' }}>SSR/SSG</span> - les mêmes ID sur le serveur et le client</li>
190 <li><span style={{ color: '#00d4ff' }}>Accessibilité</span> - htmlFor/id/aria-describedby stables</li>
191 <li><span style={{ color: '#ffaa00' }}>Instances multiples</span> - chacune a un ID unique</li>
192 <li><span style={{ color: '#ff6b6b' }}>Hydratation</span> - aucun mismatch lors de la réhydratation</li>
193 </ul>
194 </div>
195 </div>
196 </div>
197 );
198}Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. L'objectif principal de useCallback est :
2. Quelle est la principale différence entre useMemo et useCallback ?
Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Éditeur de code
Le fichier App.jsx contient un catalogue de huit planètes avec un champ de recherche, une liste de tri et un bouton « Signal radio » qui modifie un autre état. La fonction filterPlanets simule un calcul coûteux et écrit un message dans la console à chaque appel. Complète les trous : ___BLANK1___ est le hook qui mémorise le résultat d'un calcul entre les rendus, et ___BLANK2___ sont les variables d'état dont dépend le résultat (la liste des dépendances). Saisir un texte et changer le tri doivent recalculer la liste, cliquer sur « Signal radio » non. Vérifie dans la console de l'aperçu quand le message apparaît.
- Clique dans l'ordre
Reconstitue la syntaxe du hook useMemo pour mémoïser des calculs coûteux :