We use cookies to enhance your experience on the site
CodeWorlds

useId - unique identifiers for accessibility

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.

useId Basics

1. The problem useId solves

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}

2. Basic usage for single elements

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}

3. Usage with multiple related elements

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}

Advanced patterns with useId

1. Components with ARIA descriptions

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}

2. Accordion/collapse components

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          }}>
5152          </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}

3. Advanced forms with validation

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}

Practical applications of useId

1. Reusable components

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              >
8283              </label>
84            </React.Fragment>
85          );
86        })}
87      </div>
88    </div>
89  );
90}

2. Components with tooltips

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}

Best practices with useId

1. When to use useId

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}

2. Prefixes for better organization

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}

3. Testing components with useId

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.

Go to CodeWorlds