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.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}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}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}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
78// Przykład użycia z walidacją
79function ContactForm() {
80 const [email, setEmail] = useState('');
81 const [phone, setPhone] = useState('');
82 const [errors, setErrors] = useState({});
83
84 const validateEmail = (value) => {
85 if (!value) return 'Email jest wymagany';
86 if (!/^[^s@]+@[^s@]+.[^s@]+$/.test(value)) {
87 return 'Nieprawidłowy format email';
88 }
89 return '';
90 };
91
92 const validatePhone = (value) => {
93 if (value && !/^d{9}$/.test(value.replace(/s/g, ''))) {
94 return 'Numer telefonu powinien mieć 9 cyfr';
95 }
96 return '';
97 };
98
99 const handleEmailChange = (e) => {
100 const value = e.target.value;
101 setEmail(value);
102 setErrors(prev => ({ ...prev, email: validateEmail(value) }));
103 };
104
105 const handlePhoneChange = (e) => {
106 const value = e.target.value;
107 setPhone(value);
108 setErrors(prev => ({ ...prev, phone: validatePhone(value) }));
109 };
110
111 return (
112 <form style={{ maxWidth: '500px', margin: '0 auto' }}>
113 <h2>Formularz kontaktowy</h2>
114
115 <AccessibleTextField
116 label="Adres email"
117 type="email"
118 value={email}
119 onChange={handleEmailChange}
120 helperText="Użyjemy tego adresu tylko do kontaktu w sprawie Twojego zapytania"
121 errorText={errors.email}
122 required
123 />
124
125 <AccessibleTextField
126 label="Numer telefonu"
127 type="tel"
128 value={phone}
129 onChange={handlePhoneChange}
130 helperText="Opcjonalnie - przyspieszy kontakt"
131 errorText={errors.phone}
132 />
133
134 <button
135 type="submit"
136 disabled={!!errors.email || !!errors.phone}
137 style={{
138 padding: '10px 20px',
139 backgroundColor: '#007bff',
140 color: 'white',
141 border: 'none',
142 borderRadius: '4px',
143 cursor: 'pointer',
144 fontSize: '16px'
145 }}
146 >
147 Wyślij
148 </button>
149 </form>
150 );
151}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}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
34 return (
35 <div style={{ marginBottom: '20px' }}>
36 <label
37 htmlFor={inputId}
38 style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}
39 >
40 {label}
41 </label>
42
43 <div style={{ position: 'relative' }}>
44 <input
45 id={inputId}
46 type={showPassword ? 'text' : 'password'}
47 value={value}
48 onChange={onChange}
49 aria-describedby={`${strengthId} ${requirementsId}`}
50 style={{
51 width: '100%',
52 padding: '10px 40px 10px 10px',
53 border: '2px solid #ddd',
54 borderRadius: '4px',
55 fontSize: '16px'
56 }}
57 />
58
59 <button
60 type="button"
61 onClick={() => setShowPassword(!showPassword)}
62 aria-label={showPassword ? 'Ukryj hasło' : 'Pokaż hasło'}
63 style={{
64 position: 'absolute',
65 right: '10px',
66 top: '50%',
67 transform: 'translateY(-50%)',
68 background: 'none',
69 border: 'none',
70 cursor: 'pointer',
71 fontSize: '20px'
72 }}
73 >
74 {showPassword ? '👁️' : '👁️🗨️'}
75 </button>
76 </div>
77
78 {showStrength && value && (
79 <>
80 <div
81 id={strengthId}
82 style={{ marginTop: '10px' }}
83 role="status"
84 aria-live="polite"
85 >
86 <div style={{
87 display: 'flex',
88 justifyContent: 'space-between',
89 marginBottom: '5px',
90 fontSize: '14px'
91 }}>
92 <span>Siła hasła:</span>
93 <span style={{ color: strengthColors[strength], fontWeight: 'bold' }}>
94 {strengthLabels[strength]}
95 </span>
96 </div>
97
98 <div style={{
99 height: '4px',
100 backgroundColor: '#e9ecef',
101 borderRadius: '2px',
102 overflow: 'hidden'
103 }}>
104 <div style={{
105 width: `${(strength + 1) * 20}%`,
106 height: '100%',
107 backgroundColor: strengthColors[strength],
108 transition: 'all 0.3s ease'
109 }} />
110 </div>
111 </div>
112
113 <ul
114 id={requirementsId}
115 style={{
116 marginTop: '10px',
117 paddingLeft: '20px',
118 fontSize: '14px'
119 }}
120 >
121 {requirements.map((req, index) => (
122 <li
123 key={index}
124 style={{
125 color: req.met ? '#28a745' : '#6c757d',
126 listStyleType: req.met ? '✓' : '○'
127 }}
128 >
129 {req.text}
130 </li>
131 ))}
132 </ul>
133 </>
134 )}
135 </div>
136 );
137}
138
139// Kompletny formularz rejestracji
140function SecureRegistrationForm() {
141 const [formData, setFormData] = useState({
142 username: '',
143 email: '',
144 password: '',
145 confirmPassword: ''
146 });
147
148 const [agreed, setAgreed] = useState(false);
149 const checkboxId = useId();
150
151 const handleSubmit = (e) => {
152 e.preventDefault();
153 console.log('Form submitted:', formData);
154 };
155
156 const passwordsMatch = formData.password === formData.confirmPassword;
157
158 return (
159 <form
160 onSubmit={handleSubmit}
161 style={{ maxWidth: '500px', margin: '0 auto', padding: '20px' }}
162 >
163 <h2>Bezpieczna rejestracja</h2>
164
165 <AccessibleTextField
166 label="Nazwa użytkownika"
167 value={formData.username}
168 onChange={(e) => setFormData(prev => ({ ...prev, username: e.target.value }))}
169 helperText="3-20 znaków, tylko litery i cyfry"
170 required
171 />
172
173 <AccessibleTextField
174 label="Email"
175 type="email"
176 value={formData.email}
177 onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
178 required
179 />
180
181 <PasswordField
182 label="Hasło"
183 value={formData.password}
184 onChange={(e) => setFormData(prev => ({ ...prev, password: e.target.value }))}
185 />
186
187 <PasswordField
188 label="Potwierdź hasło"
189 value={formData.confirmPassword}
190 onChange={(e) => setFormData(prev => ({ ...prev, confirmPassword: e.target.value }))}
191 showStrength={false}
192 />
193
194 {formData.confirmPassword && !passwordsMatch && (
195 <div style={{ color: '#dc3545', fontSize: '14px', marginTop: '-15px', marginBottom: '15px' }}>
196 Hasła nie są identyczne
197 </div>
198 )}
199
200 <div style={{ marginBottom: '20px' }}>
201 <input
202 type="checkbox"
203 id={checkboxId}
204 checked={agreed}
205 onChange={(e) => setAgreed(e.target.checked)}
206 style={{ marginRight: '8px' }}
207 />
208 <label htmlFor={checkboxId} style={{ fontSize: '14px' }}>
209 Akceptuję <a href="#">regulamin</a> i <a href="#">politykę prywatności</a>
210 </label>
211 </div>
212
213 <button
214 type="submit"
215 disabled={!agreed || !passwordsMatch || !formData.password}
216 style={{
217 width: '100%',
218 padding: '12px',
219 backgroundColor: '#007bff',
220 color: 'white',
221 border: 'none',
222 borderRadius: '4px',
223 fontSize: '16px',
224 cursor: 'pointer',
225 opacity: (!agreed || !passwordsMatch || !formData.password) ? 0.6 : 1
226 }}
227 >
228 Zarejestruj się
229 </button>
230 </form>
231 );
232}1import { useId } 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 <React.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 </React.Fragment>
85 );
86 })}
87 </div>
88 </div>
89 );
90}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}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}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-:r1:, form-:r2:
20}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});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.