Kurs JavaScript i React · Moduł 9: Nowoczesne hooki React

useId - unikalne identyfikatory dla dostępności

16 min czytania
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}
77

Opis 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  ];
33

Sił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}
105

Input 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. 1. Główny cel useCallback to:

  2. 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ń:

Przydatne artykuły