JavaScript and React course Β· Module 9: Modern React Hooks
useId - unique identifiers for accessibility
In this lesson5
On board, every switch has a label plate. If two switches get the same plate, an astronaut will pull the wrong one. In HTML, the role of that plate is played by linking <label htmlFor> to the field's id, and screen readers rely on it completely. The trouble starts when the same form component appears on screen three times: where do you get three different, predictable identifiers?
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
Let's start with two popular but wrong ideas, and then compare them with useId:
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}Math.random() returns a different value on the server and in the browser, so hydration reports a mismatch. A global counter depends on rendering order, which can differ with SSR and Suspense. useId is based on the component's position in the tree, so the server and the client compute the same identifier.
2. Basic usage for single elements
The most common scenario is a field component that links its label and input by itself. Every instance calls the hook separately:
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}The four AccessibleInput fields get four different identifiers, even though they share the same code. Clicking a label focuses the field, and a screen reader announces its name.
3. Usage with multiple related elements
When you need many identifiers, do not call the hook in a loop. Call it once and append suffixes:
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}Every radio group has its own baseId, so options from the "theme" group never collide with the "language" group. Notice that list keys still come from the data, option.value, not from useId.
Advanced patterns with useId
1. Components with ARIA descriptions
The aria-describedby attribute points to the identifiers of elements with helper text or an error, and aria-invalid says the value is incorrect. First, the field component:
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}
77The description and the error are attached to the field only when they exist, and role="alert" makes the screen reader announce the error right away. Now the form that uses it:
1// Example usage with validation
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 is required';
9 if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
10 return 'Invalid email format';
11 }
12 return '';
13 };
14
15 const validatePhone = (value) => {
16 if (value && !/^\d{9}$/.test(value.replace(/\s/g, ''))) {
17 return 'Phone number should have 9 digits';
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>Contact Form</h2>
37
38 <AccessibleTextField
39 label="Email address"
40 type="email"
41 value={email}
42 onChange={handleEmailChange}
43 helperText="We will use this address only for contacting you about your inquiry"
44 errorText={errors.email}
45 required
46 />
47
48 <AccessibleTextField
49 label="Phone number"
50 type="tel"
51 value={phone}
52 onChange={handlePhoneChange}
53 helperText="Optional - speeds up contact"
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 Send
71 </button>
72 </form>
73 );
74}The form knows no identifiers at all, because AccessibleTextField manages them internally.
2. Accordion/collapse components
The accordion pattern from the WAI-ARIA guidelines requires linking the button and the panel through aria-controls and aria-labelledby. Two useId calls in AccordionItem are enough:
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}The aria-expanded attribute tells whether the panel is open, and hidden hides the closed content from screen readers too.
3. Advanced forms with validation
The password field has three related elements: the input, the strength meter and the list of requirements. First, the password scoring logic:
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 ];
33Strength is the number of satisfied conditions, from 0 to 4, which matches the five labels in strengthLabels. Now the field's view:
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 ? 'Hide password' : 'Show password'}
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 ? 'Hide' : 'Show'}
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>Password strength:</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}
105Through aria-describedby the input points to both identifiers at once, and aria-live="polite" announces strength changes without interrupting the user. We put it all together in a registration form:
1// Complete registration form
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>Secure Registration</h2>
26
27 <AccessibleTextField
28 label="Username"
29 value={formData.username}
30 onChange={(e) => setFormData(prev => ({ ...prev, username: e.target.value }))}
31 helperText="3-20 characters, letters and digits only"
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="Password"
45 value={formData.password}
46 onChange={(e) => setFormData(prev => ({ ...prev, password: e.target.value }))}
47 />
48
49 <PasswordField
50 label="Confirm password"
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 Passwords do not match
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 I accept the <a href="#">terms of service</a> and <a href="#">privacy policy</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 Register
91 </button>
92 </form>
93 );
94}The consent checkbox gets its own useId directly in the form, because it is not a separate component.
Practical applications of useId
1. Reusable components
A switch and a star rating are classic UI library components that can appear many times on a page:
1import { useId, Fragment } 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 <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}Watch out for one detail: display: 'none' removes the input from the accessibility tree and from keyboard navigation. In production it is better to hide it visually with a "visually hidden" class, so the switch can be reached with the Tab key.
2. Components with tooltips
A tooltip should be linked to its button through aria-describedby and appear on focus too, not only on mouse hover:
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}The tooltip role together with onFocus and onBlur handling gives keyboard users the same information as mouse users.
Best practices with useId
1. When to use useId
The examples below collect good and bad uses:
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}The react.dev docs say it plainly: useId is not meant for generating list keys or data identifiers. Keys should come from your data, and the hook cannot be called in a loop anyway.
2. Prefixes for better organization
You can wrap the hook in your own function that adds a prefix:
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-_r_1_, form-_r_2_ (older React versions: form-:r1:)
20}The format of the identifier itself depends on the React version, so do not build any logic on it. When several React apps run on one page, use the official identifierPrefix option of createRoot or hydrateRoot.
3. Testing components with useId
Tests should not depend on generated values. Look fields up by their label, just like a user does:
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});getByLabelText only works when the label is correctly linked to the field, so the test checks accessibility along the way.
Review: memoization before the exercises
The exercises after this lesson return to memoization. useMemo remembers the result of a computation, while useCallback remembers the function itself, that is its stable reference between renders. memo skips a component's render when its props have not changed:
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: remembered RESULT of filtering
11 const visible = useMemo(
12 () => planets.filter(p => p.name.includes(query)),
13 [planets, query]
14 );
15 // useCallback: remembered FUNCTION (same reference)
16 const handleSelect = useCallback((id) => console.log('Selected', id), []);
17
18 return <PlanetList planets={visible} onSelect={handleSelect} />;
19}Without useCallback, every new handleSelect function would defeat memo. Do not wrap everything, though: comparing props costs something too, and for simple components it can be more expensive than the render itself.
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.
My advice: give every form field in your component library a useId from day one. Remember: useId is the label plate next to the switch that lets every crew member pull the right one.
Code for this lesson: App.jsx
1import React, { useId, useState } from 'react';
2
3// Demonstration of useId - generating stable, unique identifiers
4// Key for accessibility and SSR
5
6// Form component with useId - each instance has a unique ID
7function CrewMemberForm({ title }) {
8 // useId generates a unique prefix for this component instance
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 ? 'Name must have at least 2 characters' : '';
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 Crew member name:
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="e.g. Captain 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 Position:
57 </label>
58 <input
59 id={roleId}
60 value={role}
61 onChange={e => setRole(e.target.value)}
62 placeholder="e.g. Navigator"
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 Access level:
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">Standard</option>
78 <option value="elevated">Elevated</option>
79 <option value="command">Command</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// Component with multiple radio groups - each needs a unique name
96function MissionPrioritySelector() {
97 const id = useId();
98 const [priority, setPriority] = useState('medium');
99
100 const priorities = [
101 { value: 'low', label: 'Low', color: '#00ff88' },
102 { value: 'medium', label: 'Medium', color: '#ffaa00' },
103 { value: 'high', label: 'High', color: '#ff6b6b' },
104 { value: 'critical', label: 'Critical', 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' }}>Mission priority</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 Choose mission priority
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> (unique thanks to useId)
144 </p>
145 </div>
146 );
147}
148
149// Main app - multiple instances of the same component
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 - Crew Registration</h1>
157 <p style={{ color: '#778da9', textAlign: 'center', fontSize: '13px', marginBottom: '20px' }}>
158 Each form instance has a unique ID generated by useId
159 </p>
160
161 <div style={{ display: 'grid', gap: '16px' }}>
162 {Array.from({ length: formCount }, (_, i) => (
163 <CrewMemberForm key={i} title={'Crew Member #' + (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 + Add form
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 - Remove last
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' }}>Why useId instead of 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> - same IDs on the server and client</li>
190 <li><span style={{ color: '#00d4ff' }}>Accessibility</span> - stable htmlFor/id/aria-describedby</li>
191 <li><span style={{ color: '#ffaa00' }}>Multiple instances</span> - each one has a unique ID</li>
192 <li><span style={{ color: '#ff6b6b' }}>Hydration</span> - no mismatch during rehydration</li>
193 </ul>
194 </div>
195 </div>
196 </div>
197 );
198}Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. The main purpose of useCallback is:
2. What is the main difference between useMemo and useCallback?
These are 2 of 3 questions for this lesson. Solve the rest in the game.
Hands-on tasks in the game
- Code editor
App.jsx contains a catalog of eight planets with a search field, a sort list and a "Radio signal" button that changes another state. The filterPlanets function pretends to be an expensive calculation and logs a message to the console every time it runs. Fill in the blanks: ___BLANK1___ is the hook that remembers the result of a calculation between renders, and ___BLANK2___ are the state variables the result depends on (the dependency list). Typing a text and changing the sorting should recalculate the list, clicking "Radio signal" should not. Check in the preview console when the message appears.
- Click in order
Arrange the syntax of the useMemo hook for memoizing expensive computations: