Kurs JavaScript i React · Moduł 9: Nowoczesne hooki React
useId - unikalne identyfikatory dla dostępności
W tej lekcji5
Na pokładzie każdy przełącznik ma tabliczkę z opisem. Jeśli dwa przełączniki dostaną tę samą tabliczkę, astronauta pociągnie nie ten, co trzeba. W HTML rolę takiej tabliczki pełni powiązanie <label htmlFor> z id pola, a czytniki ekranu polegają na nim całkowicie. Kłopot zaczyna się, gdy ten sam komponent formularza pojawia się na ekranie trzy razy: skąd wziąć trzy różne, przewidywalne identyfikatory?
Hook useId jest prostym, ale niezwykle ważnym narzędziem wprowadzonym w React 18. Generuje unikalne identyfikatory, które są stabilne zarówno po stronie serwera, jak i klienta, co jest kluczowe dla dostępności (accessibility) i poprawnego renderowania SSR.
Podstawy useId
1. Problem, który rozwiązuje useId
Zacznijmy od dwóch popularnych, ale błędnych pomysłów, a potem porównajmy je z useId:
1// PROBLEM - niestabilne ID
2function BadExample() {
3 // Math.random() da różne wartości na serwerze i kliencie!
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// PROBLEM - globalne liczniki
15let counter = 0;
16function AnotherBadExample() {
17 // Counter może być różny w różnych renderach
18 const id = `input-${counter++}`;
19
20 return (
21 <div>
22 <label htmlFor={id}>Hasło:</label>
23 <input id={id} type="password" />
24 </div>
25 );
26}
27
28// ROZWIĄZANIE - useId
29import { useId } from 'react';
30
31function GoodExample() {
32 const id = useId();
33
34 return (
35 <div>
36 <label htmlFor={id}>Imię:</label>
37 <input id={id} type="text" />
38 </div>
39 );
40}Math.random() daje inną wartość na serwerze i w przeglądarce, więc hydratacja zgłosi niezgodność. Globalny licznik zależy od kolejności renderowania, która przy SSR i Suspense bywa różna. useId opiera się na pozycji komponentu w drzewie, dlatego serwer i klient wyliczą ten sam identyfikator.
2. Podstawowe użycie dla pojedynczych elementów
Najczęstszy scenariusz to komponent pola, który sam łączy etykietę z inputem. Każda instancja wywołuje hook osobno:
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// Użycie komponentu
34function RegistrationForm() {
35 return (
36 <form style={{ maxWidth: '400px', margin: '0 auto' }}>
37 <h2>Rejestracja</h2>
38 <AccessibleInput label="Imię:" />
39 <AccessibleInput label="Nazwisko:" />
40 <AccessibleInput label="Email:" type="email" />
41 <AccessibleInput label="Hasło:" type="password" />
42 <button type="submit">Zarejestruj się</button>
43 </form>
44 );
45}Cztery pola AccessibleInput dostają cztery różne identyfikatory, choć korzystają z tego samego kodu. Kliknięcie etykiety ustawia fokus w polu, a czytnik ekranu odczyta jego nazwę.
3. Użycie z wieloma powiązanymi elementami
Gdy potrzebujesz wielu identyfikatorów, nie wywołuj hooka w pętli. Wywołaj go raz i doklejaj przyrostki:
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// Użycie
33function PreferencesForm() {
34 const handleThemeChange = (e) => console.log('Theme:', e.target.value);
35 const handleLanguageChange = (e) => console.log('Language:', e.target.value);
36
37 return (
38 <div style={{ maxWidth: '500px', margin: '0 auto' }}>
39 <h2>Preferencje</h2>
40
41 <RadioGroup
42 label="Wybierz motyw:"
43 name="theme"
44 onChange={handleThemeChange}
45 options={[
46 { value: 'light', label: 'Jasny' },
47 { value: 'dark', label: 'Ciemny' },
48 { value: 'auto', label: 'Automatyczny' }
49 ]}
50 />
51
52 <div style={{ marginTop: '20px' }}>
53 <RadioGroup
54 label="Język interfejsu:"
55 name="language"
56 onChange={handleLanguageChange}
57 options={[
58 { value: 'pl', label: 'Polski' },
59 { value: 'en', label: 'English' },
60 { value: 'de', label: 'Deutsch' }
61 ]}
62 />
63 </div>
64 </div>
65 );
66}Każda grupa radiowa ma własne baseId, więc opcje z grupy "motyw" nie zderzą się z opcjami grupy "język". Zauważ, że klucze listy dalej pochodzą z danych, option.value, a nie z useId.
Zaawansowane wzorce z useId
1. Komponenty z opisami ARIA
Atrybut aria-describedby wskazuje identyfikatory elementów z tekstem pomocniczym lub błędem, a aria-invalid informuje, że wartość jest niepoprawna. Najpierw komponent pola:
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}
77Opis i błąd są dołączane do pola tylko wtedy, gdy istnieją, a role="alert" sprawia, że czytnik od razu ogłosi błąd. Teraz formularz, który z niego korzysta:
1// Przykład użycia z walidacją
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 'Email jest wymagany';
9 if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
10 return 'Nieprawidłowy format email';
11 }
12 return '';
13 };
14
15 const validatePhone = (value) => {
16 if (value && !/^\d{9}$/.test(value.replace(/\s/g, ''))) {
17 return 'Numer telefonu powinien mieć 9 cyfr';
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>Formularz kontaktowy</h2>
37
38 <AccessibleTextField
39 label="Adres email"
40 type="email"
41 value={email}
42 onChange={handleEmailChange}
43 helperText="Użyjemy tego adresu tylko do kontaktu w sprawie Twojego zapytania"
44 errorText={errors.email}
45 required
46 />
47
48 <AccessibleTextField
49 label="Numer telefonu"
50 type="tel"
51 value={phone}
52 onChange={handlePhoneChange}
53 helperText="Opcjonalnie - przyspieszy kontakt"
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 Wyślij
71 </button>
72 </form>
73 );
74}Formularz nie zna żadnych identyfikatorów, bo to AccessibleTextField zarządza nimi w środku.
2. Komponenty typu accordion/collapse
Wzorzec akordeonu z wytycznych WAI-ARIA wymaga powiązania przycisku z panelem przez aria-controls i aria-labelledby. Dwa wywołania useId w AccordionItem wystarczą:
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// Użycie
76function FAQSection() {
77 const faqItems = [
78 {
79 title: 'Czym jest useId?',
80 content: 'useId to hook React, który generuje unikalne identyfikatory stabilne między serwerem a klientem. Jest szczególnie przydatny dla dostępności.'
81 },
82 {
83 title: 'Kiedy używać useId?',
84 content: 'Używaj useId zawsze, gdy potrzebujesz unikalnych ID dla elementów HTML, szczególnie dla powiązań label-input, ARIA attributes, czy innych relacji między elementami.'
85 },
86 {
87 title: 'Czy useId jest bezpieczny dla SSR?',
88 content: 'Tak! useId został specjalnie zaprojektowany, aby generować te same ID na serwerze i kliencie, eliminując problemy z hydratacją.'
89 }
90 ];
91
92 return (
93 <div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
94 <h2>Często zadawane pytania</h2>
95 <Accordion items={faqItems} />
96 </div>
97 );
98}Atrybut aria-expanded mówi, czy panel jest otwarty, a hidden ukrywa zamkniętą treść także przed czytnikiem.
3. Zaawansowane formularze z walidacją
Pole hasła ma trzy powiązane elementy: input, wskaźnik siły i listę wymagań. Najpierw logika oceny hasła:
1import { useId, useState } from 'react';
2
3function PasswordField({
4 label = "Hasło",
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 = ['Bardzo słabe', 'Słabe', 'Średnie', 'Silne', 'Bardzo silne'];
25 const strengthColors = ['#dc3545', '#fd7e14', '#ffc107', '#28a745', '#20c997'];
26
27 const requirements = [
28 { met: value.length >= 8, text: 'Minimum 8 znaków' },
29 { met: /[a-z]/.test(value) && /[A-Z]/.test(value), text: 'Małe i wielkie litery' },
30 { met: /\d/.test(value), text: 'Przynajmniej jedna cyfra' },
31 { met: /[^a-zA-Z0-9]/.test(value), text: 'Przynajmniej jeden znak specjalny' }
32 ];
33Siła to liczba spełnionych warunków, od 0 do 4, co pasuje do pięciu etykiet w strengthLabels. Teraz widok pola:
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 ? 'Ukryj hasło' : 'Pokaż hasło'}
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 ? 'Ukryj' : 'Pokaż'}
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>Siła hasła:</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}
105Input wskazuje przez aria-describedby oba identyfikatory naraz, a aria-live="polite" ogłasza zmianę siły bez przerywania użytkownika. Całość składamy w formularz rejestracji:
1// Kompletny formularz rejestracji
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('Form submitted:', 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>Bezpieczna rejestracja</h2>
26
27 <AccessibleTextField
28 label="Nazwa użytkownika"
29 value={formData.username}
30 onChange={(e) => setFormData(prev => ({ ...prev, username: e.target.value }))}
31 helperText="3-20 znaków, tylko litery i cyfry"
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="Hasło"
45 value={formData.password}
46 onChange={(e) => setFormData(prev => ({ ...prev, password: e.target.value }))}
47 />
48
49 <PasswordField
50 label="Potwierdź hasło"
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 Hasła nie są identyczne
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 Akceptuję <a href="#">regulamin</a> i <a href="#">politykę prywatności</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 Zarejestruj się
91 </button>
92 </form>
93 );
94}Checkbox zgody dostał własne useId bezpośrednio w formularzu, bo nie jest osobnym komponentem.
Praktyczne zastosowania useId
1. Komponenty wielokrotnego użytku
Przełącznik i ocena gwiazdkami to klasyczne komponenty z biblioteki UI, które mogą wystąpić wiele razy na stronie:
1import { useId, Fragment } from 'react';
2
3// Uniwersalny komponent Toggle/Switch
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// Komponent do oceny (rating)
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}Uwaga na jeden szczegół: display: 'none' usuwa input z drzewa dostępności i z nawigacji klawiaturą. W produkcji lepiej ukryć go wizualnie klasą typu "visually hidden", żeby przełącznik dało się obsłużyć klawiszem Tab.
2. Komponenty z podpowiedziami (tooltip)
Podpowiedź powinna być powiązana z przyciskiem przez aria-describedby i pojawiać się także przy fokusie, nie tylko po najechaniu myszą:
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}Rola tooltip i obsługa onFocus oraz onBlur sprawiają, że użytkownik klawiatury dostaje tę samą informację co użytkownik myszy.
Najlepsze praktyki z useId
1. Kiedy używać useId
Poniższe przykłady zbierają dobre i złe zastosowania:
1// DOBRZE - elementy formularza
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// DOBRZE - ARIA relationships
13function GoodAria() {
14 const descriptionId = useId();
15 return (
16 <>
17 <input aria-describedby={descriptionId} />
18 <span id={descriptionId}>Pomocny opis</span>
19 </>
20 );
21}
22
23// ŹLE - jako klucze w listach
24function BadList({ items }) {
25 return items.map(item => {
26 const id = useId(); // Nie używaj w pętlach!
27 return <li key={id}>{item}</li>;
28 });
29}
30
31// ŹLE - do identyfikacji danych
32function BadData() {
33 const user = {
34 id: useId(), // Nie do danych!
35 name: 'Jan'
36 };
37}Dokumentacja react.dev mówi wprost: useId nie służy do generowania kluczy listy ani identyfikatorów danych. Klucze powinny pochodzić z danych, a hook i tak nie może być wywołany w pętli.
2. Prefiksy dla lepszej organizacji
Możesz opakować hook we własną funkcję dodającą prefiks:
1import { useId } from 'react';
2
3function createIdGenerator(prefix) {
4 return function useIdWithPrefix() {
5 const id = useId();
6 return `${prefix}-${id}`;
7 };
8}
9
10// Użycie
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 // Wygenerowane ID będą wyglądać jak: form-_r_1_, form-_r_2_ (starsze wersje React: form-:r1:)
20}Format samego identyfikatora zależy od wersji React, dlatego nie buduj na nim żadnej logiki. Gdy na jednej stronie działa kilka aplikacji React, użyj oficjalnej opcji identifierPrefix w createRoot lub hydrateRoot.
3. Testowanie komponentów z useId
Testy nie powinny zależeć od wygenerowanych wartości. Szukaj pól po etykiecie, tak jak użytkownik:
1// Komponenty używające useId są łatwe do testowania
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}>Username:</label>
12 <input id={usernameId} type="text" />
13
14 <label htmlFor={passwordId}>Password:</label>
15 <input id={passwordId} type="password" />
16
17 <button type="submit">Login</button>
18 </form>
19 );
20}
21
22// Test
23test('form inputs are properly labeled', async () => {
24 render(<LoginForm />);
25
26 // Możemy znaleźć inputy przez ich labels
27 const usernameInput = screen.getByLabelText('Username:');
28 const passwordInput = screen.getByLabelText('Password:');
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 działa tylko wtedy, gdy etykieta jest poprawnie powiązana z polem, więc test sprawdza przy okazji dostępność.
Powtórka: memoizacja przed zadaniami
Zadania po tej lekcji wracają do memoizacji. useMemo zapamiętuje wynik obliczeń, a useCallback samą funkcję, czyli jej stabilną referencję między renderami. memo pomija render komponentu, gdy propsy się nie zmieniły:
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: zapamiętany WYNIK filtrowania
11 const visible = useMemo(
12 () => planets.filter(p => p.name.includes(query)),
13 [planets, query]
14 );
15 // useCallback: zapamiętana FUNKCJA (ta sama referencja)
16 const handleSelect = useCallback((id) => console.log('Wybrano', id), []);
17
18 return <PlanetList planets={visible} onSelect={handleSelect} />;
19}Bez useCallback każda nowa funkcja handleSelect unieważniałaby memo. Nie owijaj jednak wszystkiego: porównywanie propsów też kosztuje i przy prostych komponentach bywa droższe niż sam render.
useId to prosty, ale potężny hook, który znacząco poprawia dostępność aplikacji React. Używaj go wszędzie tam, gdzie potrzebujesz stabilnych, unikalnych identyfikatorów dla elementów HTML.
Moja rada: każde pole formularza w bibliotece komponentów niech od razu dostaje useId. Pamiętaj: useId to tabliczka przy przełączniku, dzięki której każdy członek załogi trafi we właściwy.
Kod do tej lekcji: App.jsx
1import React, { useId, useState } from 'react';
2
3// Demonstracja useId - generowanie stabilnych, unikalnych identyfikatorów
4// Kluczowe dla dostępności (accessibility) i SSR
5
6// Komponent formularza z useId - każda instancja ma unikalne ID
7function CrewMemberForm({ title }) {
8 // useId generuje unikalny prefix dla tej instancji komponentu
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 ? 'Imię musi mieć min. 2 znaki' : '';
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 Imię członka załogi:
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="np. Kapitan 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 Stanowisko:
57 </label>
58 <input
59 id={roleId}
60 value={role}
61 onChange={e => setRole(e.target.value)}
62 placeholder="np. Nawigator"
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 Poziom dostępu:
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">Standardowy</option>
78 <option value="elevated">Podwyższony</option>
79 <option value="command">Dowódczy</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// Komponent z wieloma grupami radio - każda potrzebuje unikalnej nazwy
96function MissionPrioritySelector() {
97 const id = useId();
98 const [priority, setPriority] = useState('medium');
99
100 const priorities = [
101 { value: 'low', label: 'Niski', color: '#00ff88' },
102 { value: 'medium', label: 'Średni', color: '#ffaa00' },
103 { value: 'high', label: 'Wysoki', color: '#ff6b6b' },
104 { value: 'critical', label: 'Krytyczny', 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' }}>Priorytet misji</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 Wybierz priorytet misji
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 Radio group name: <code style={{ color: '#00d4ff' }}>{id}-priority</code> (unikalna dzięki useId)
144 </p>
145 </div>
146 );
147}
148
149// Główna aplikacja - wiele instancji tego samego komponentu
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 - Rejestracja załogi</h1>
157 <p style={{ color: '#778da9', textAlign: 'center', fontSize: '13px', marginBottom: '20px' }}>
158 Każda instancja formularza ma unikalne ID generowane przez useId
159 </p>
160
161 <div style={{ display: 'grid', gap: '16px' }}>
162 {Array.from({ length: formCount }, (_, i) => (
163 <CrewMemberForm key={i} title={'Członek załogi #' + (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 + Dodaj formularz
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 - Usuń ostatni
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' }}>Dlaczego useId zamiast 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> - te same ID na serwerze i kliencie</li>
190 <li><span style={{ color: '#00d4ff' }}>Dostępność</span> - stabilne htmlFor/id/aria-describedby</li>
191 <li><span style={{ color: '#ffaa00' }}>Wielokrotne instancje</span> - każda ma unikalne ID</li>
192 <li><span style={{ color: '#ff6b6b' }}>Hydratacja</span> - brak niezgodności po stronie przeglądarki</li>
193 </ul>
194 </div>
195 </div>
196 </div>
197 );
198}Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Główny cel useCallback to:
2. Jaka jest główna różnica między useMemo a useCallback?
To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Edytor kodu
W pliku App.jsx jest katalog ośmiu planet z polem wyszukiwania, listą sortowania i przyciskiem „Sygnał radiowy”, który zmienia inny stan. Funkcja filterPlanets udaje kosztowne obliczenie i przy każdym wywołaniu wypisuje komunikat w konsoli. Uzupełnij luki: ___BLANK1___ to hook, który zapamiętuje wynik obliczenia między renderami, a ___BLANK2___ to zmienne stanu, od których zależy wynik (lista zależności). Wpisanie tekstu i zmiana sortowania mają przeliczyć listę, a kliknięcie „Sygnał radiowy” już nie. Sprawdź w konsoli podglądu, kiedy pojawia się komunikat.
- Klikanie w kolejności
Ułóż składnię hooka useMemo do memoizacji kosztownych obliczeń: