The
useId hook is a simple but extremely important tool introduced in React 18. It generates unique identifiers that are stable on both the server and client sides, which is crucial for accessibility and proper SSR rendering.1// BAD - unstable ID
2function BadExample() {
3 // Math.random() will give different values on server and client!
4 const id = Math.random().toString(36).substr(2, 9);
5
6 return (
7 <div>
8 <label htmlFor={id}>Email:</label>
9 <input id={id} type="email" />
10 </div>
11 );
12}
13
14// BAD - global counters
15let counter = 0;
16function AnotherBadExample() {
17 // Counter may differ between renders
18 const id = `input-${counter++}`;
19
20 return (
21 <div>
22 <label htmlFor={id}>Password:</label>
23 <input id={id} type="password" />
24 </div>
25 );
26}
27
28// GOOD - useId
29import { useId } from 'react';
30
31function GoodExample() {
32 const id = useId();
33
34 return (
35 <div>
36 <label htmlFor={id}>Name:</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// Using the component
34function RegistrationForm() {
35 return (
36 <form style={{ maxWidth: '400px', margin: '0 auto' }}>
37 <h2>Registration</h2>
38 <AccessibleInput label="First name:"
39 <AccessibleInput label="Last name:"
40 <AccessibleInput label="Email:" type="email"
41 <AccessibleInput label="Password:" type="password" />
42 <button type="submit">Register</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// Usage
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>Preferences</h2>
40
41 <RadioGroup
42 label="Choose theme:"
43 name="theme"
44 onChange={handleThemeChange}
45 options={[
46 { value: 'light', label: 'Light' },
47 { value: 'dark', label: 'Dark' },
48 { value: 'auto', label: 'Automatic' }
49 ]}
50 />
51
52 <div style={{ marginTop: '20px' }}>
53 <RadioGroup
54 label="Interface language:"
55 name="language"
56 onChange={handleLanguageChange}
57 options={[
58 { value: 'pl', label: 'Polish' },
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// Example usage with validation
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 is required';
86 if (!/^[^s@]+@[^s@]+\.[^s@]+$/.test(value)) {
87 return 'Invalid email format';
88 }
89 return '';
90 };
91
92 const validatePhone = (value) => {
93 if (value && !/^\d{9}$/.test(value.replace(/\s/g, ''))) {
94 return 'Phone number should have 9 digits';
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>Contact Form</h2>
114
115 <AccessibleTextField
116 label="Email address"
117 type="email"
118 value={email}
119 onChange={handleEmailChange}
120 helperText="We will use this address only for contacting you about your inquiry"
121 errorText={errors.email}
122 required
123 />
124
125 <AccessibleTextField
126 label="Phone number"
127 type="tel"
128 value={phone}
129 onChange={handlePhoneChange}
130 helperText="Optional - speeds up contact"
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 Send
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// Usage
76function FAQSection() {
77 const faqItems = [
78 {
79 title: 'What is useId?',
80 content: 'useId is a React hook that generates unique identifiers stable between server and client. It is particularly useful for accessibility.'
81 },
82 {
83 title: 'When to use useId?',
84 content: 'Use useId whenever you need unique IDs for HTML elements, especially for label-input associations, ARIA attributes, or other relationships between elements.'
85 },
86 {
87 title: 'Is useId safe for SSR?',
88 content: 'Yes! useId was specifically designed to generate the same IDs on the server and client, eliminating hydration issues.'
89 }
90 ];
91
92 return (
93 <div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
94 <h2>Frequently Asked Questions</h2>
95 <Accordion items={faqItems} />
96 </div>
97 );
98}1import { useId, useState } from 'react';
2
3function PasswordField({
4 label = "Password",
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 = ['Very weak', 'Weak', 'Medium', 'Strong', 'Very strong'];
25 const strengthColors = ['#dc3545', '#fd7e14', '#ffc107', '#28a745', '#20c997'];
26
27 const requirements = [
28 { met: value.length >= 8, text: 'Minimum 8 characters' },
29 { met: /[a-z]/.test(value) && /[A-Z]/.test(value), text: 'Lowercase and uppercase letters' },
30 { met: /\d/.test(value), text: 'At least one digit' },
31 { met: /[^a-zA-Z0-9]/.test(value), text: 'At least one special character' }
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 ? 'Hide password' : 'Show password'}
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>Password strength:</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// Complete registration form
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>Secure Registration</h2>
164
165 <AccessibleTextField
166 label="Username"
167 value={formData.username}
168 onChange={(e) => setFormData(prev => ({ ...prev, username: e.target.value }))}
169 helperText="3-20 characters, letters and digits only"
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="Password"
183 value={formData.password}
184 onChange={(e) => setFormData(prev => ({ ...prev, password: e.target.value }))}
185 />
186
187 <PasswordField
188 label="Confirm password"
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 Passwords do not match
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 I accept the <a href="#">terms of service</a> and <a href="#">privacy policy</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 Register
229 </button>
230 </form>
231 );
232}1import { useId } from 'react';
2
3// Universal Toggle/Switch component
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// Rating component
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// GOOD - form elements
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// GOOD - ARIA relationships
13function GoodAria() {
14 const descriptionId = useId();
15 return (
16 <>
17 <input aria-describedby={descriptionId} />
18 <span id={descriptionId}>Helpful description</span>
19 </>
20 );
21}
22
23// BAD - as keys in lists
24function BadList({ items }) {
25 return items.map(item => {
26 const id = useId(); // Don't use in loops!
27 return <li key={id}>{item}</li>;
28 });
29}
30
31// BAD - for data identification
32function BadData() {
33 const user = {
34 id: useId(), // Not for data!
35 name: 'John'
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// Usage
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 // Generated IDs will look like: form-:r1:, form-:r2:
20}1// Components using useId are easy to test
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 // We can find inputs by their 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 is a simple but powerful hook that significantly improves the accessibility of React applications. Use it wherever you need stable, unique identifiers for HTML elements.