The useId hook generates unique IDs that are stable across server and client rendering. Here's how to use it.
Basic Usage#
1import { useId } from 'react';
2
3function EmailInput() {
4 const id = useId();
5
6 return (
7 <div>
8 <label htmlFor={id}>Email</label>
9 <input id={id} type="email" />
10 </div>
11 );
12}
13
14// Each instance gets unique ID
15function App() {
16 return (
17 <>
18 <EmailInput /> {/* id=":r0:" */}
19 <EmailInput /> {/* id=":r1:" */}
20 <EmailInput /> {/* id=":r2:" */}
21 </>
22 );
23}Multiple IDs per Component#
1function LoginForm() {
2 const id = useId();
3
4 return (
5 <form>
6 <div>
7 <label htmlFor={`${id}-email`}>Email</label>
8 <input id={`${id}-email`} type="email" />
9 </div>
10
11 <div>
12 <label htmlFor={`${id}-password`}>Password</label>
13 <input id={`${id}-password`} type="password" />
14 </div>
15
16 <div>
17 <input
18 id={`${id}-remember`}
19 type="checkbox"
20 />
21 <label htmlFor={`${id}-remember`}>Remember me</label>
22 </div>
23 </form>
24 );
25}Accessibility Attributes#
1// aria-describedby
2function PasswordInput() {
3 const id = useId();
4 const hintId = `${id}-hint`;
5 const errorId = `${id}-error`;
6
7 return (
8 <div>
9 <label htmlFor={id}>Password</label>
10 <input
11 id={id}
12 type="password"
13 aria-describedby={`${hintId} ${errorId}`}
14 />
15 <p id={hintId}>Must be at least 8 characters</p>
16 <p id={errorId} role="alert">Password is required</p>
17 </div>
18 );
19}
20
21// aria-labelledby
22function Card({ title, children }) {
23 const titleId = useId();
24
25 return (
26 <article aria-labelledby={titleId}>
27 <h2 id={titleId}>{title}</h2>
28 {children}
29 </article>
30 );
31}
32
33// aria-controls
34function Accordion({ title, children }) {
35 const id = useId();
36 const [isOpen, setIsOpen] = useState(false);
37
38 return (
39 <div>
40 <button
41 aria-expanded={isOpen}
42 aria-controls={id}
43 onClick={() => setIsOpen(!isOpen)}
44 >
45 {title}
46 </button>
47 <div id={id} hidden={!isOpen}>
48 {children}
49 </div>
50 </div>
51 );
52}Form Field Component#
1interface FormFieldProps {
2 label: string;
3 type?: string;
4 error?: string;
5 hint?: string;
6 required?: boolean;
7}
8
9function FormField({
10 label,
11 type = 'text',
12 error,
13 hint,
14 required,
15}: FormFieldProps) {
16 const id = useId();
17 const hintId = hint ? `${id}-hint` : undefined;
18 const errorId = error ? `${id}-error` : undefined;
19
20 const describedBy = [hintId, errorId].filter(Boolean).join(' ') || undefined;
21
22 return (
23 <div className="form-field">
24 <label htmlFor={id}>
25 {label}
26 {required && <span aria-hidden="true">*</span>}
27 </label>
28
29 <input
30 id={id}
31 type={type}
32 aria-describedby={describedBy}
33 aria-invalid={!!error}
34 aria-required={required}
35 />
36
37 {hint && (
38 <p id={hintId} className="hint">
39 {hint}
40 </p>
41 )}
42
43 {error && (
44 <p id={errorId} className="error" role="alert">
45 {error}
46 </p>
47 )}
48 </div>
49 );
50}
51
52// Usage
53<FormField
54 label="Username"
55 required
56 hint="3-20 characters"
57 error={errors.username}
58/>Tooltip Component#
1function Tooltip({ content, children }) {
2 const id = useId();
3 const [isVisible, setIsVisible] = useState(false);
4
5 return (
6 <span className="tooltip-wrapper">
7 <span
8 aria-describedby={isVisible ? id : undefined}
9 onMouseEnter={() => setIsVisible(true)}
10 onMouseLeave={() => setIsVisible(false)}
11 onFocus={() => setIsVisible(true)}
12 onBlur={() => setIsVisible(false)}
13 >
14 {children}
15 </span>
16
17 {isVisible && (
18 <span id={id} role="tooltip" className="tooltip">
19 {content}
20 </span>
21 )}
22 </span>
23 );
24}
25
26// Usage
27<Tooltip content="Click to submit the form">
28 <button>Submit</button>
29</Tooltip>Modal Dialog#
1function Modal({ isOpen, onClose, title, children }) {
2 const titleId = useId();
3 const descriptionId = useId();
4
5 if (!isOpen) return null;
6
7 return (
8 <div
9 role="dialog"
10 aria-modal="true"
11 aria-labelledby={titleId}
12 aria-describedby={descriptionId}
13 >
14 <h2 id={titleId}>{title}</h2>
15 <div id={descriptionId}>{children}</div>
16 <button onClick={onClose}>Close</button>
17 </div>
18 );
19}
20
21// Usage
22<Modal
23 isOpen={showModal}
24 onClose={() => setShowModal(false)}
25 title="Confirm Action"
26>
27 <p>Are you sure you want to proceed?</p>
28</Modal>Tab Panel#
1function Tabs({ tabs }) {
2 const id = useId();
3 const [activeIndex, setActiveIndex] = useState(0);
4
5 return (
6 <div>
7 <div role="tablist">
8 {tabs.map((tab, index) => (
9 <button
10 key={index}
11 role="tab"
12 id={`${id}-tab-${index}`}
13 aria-controls={`${id}-panel-${index}`}
14 aria-selected={activeIndex === index}
15 onClick={() => setActiveIndex(index)}
16 >
17 {tab.label}
18 </button>
19 ))}
20 </div>
21
22 {tabs.map((tab, index) => (
23 <div
24 key={index}
25 role="tabpanel"
26 id={`${id}-panel-${index}`}
27 aria-labelledby={`${id}-tab-${index}`}
28 hidden={activeIndex !== index}
29 >
30 {tab.content}
31 </div>
32 ))}
33 </div>
34 );
35}
36
37// Usage
38<Tabs
39 tabs={[
40 { label: 'Profile', content: <ProfilePanel /> },
41 { label: 'Settings', content: <SettingsPanel /> },
42 { label: 'Notifications', content: <NotificationsPanel /> },
43 ]}
44/>Radio Group#
1function RadioGroup({ name, options, value, onChange }) {
2 const groupId = useId();
3
4 return (
5 <div role="radiogroup" aria-labelledby={`${groupId}-label`}>
6 <span id={`${groupId}-label`} className="label">
7 {name}
8 </span>
9
10 {options.map((option, index) => {
11 const optionId = `${groupId}-option-${index}`;
12
13 return (
14 <div key={option.value}>
15 <input
16 type="radio"
17 id={optionId}
18 name={name}
19 value={option.value}
20 checked={value === option.value}
21 onChange={() => onChange(option.value)}
22 />
23 <label htmlFor={optionId}>{option.label}</label>
24 </div>
25 );
26 })}
27 </div>
28 );
29}
30
31// Usage
32<RadioGroup
33 name="Size"
34 options={[
35 { label: 'Small', value: 'sm' },
36 { label: 'Medium', value: 'md' },
37 { label: 'Large', value: 'lg' },
38 ]}
39 value={size}
40 onChange={setSize}
41/>Custom Hook with useId#
1// useFormField hook
2function useFormField(label: string) {
3 const id = useId();
4
5 return {
6 labelProps: {
7 htmlFor: id,
8 children: label,
9 },
10 inputProps: {
11 id,
12 'aria-label': label,
13 },
14 };
15}
16
17// Usage
18function Input({ label }) {
19 const { labelProps, inputProps } = useFormField(label);
20
21 return (
22 <div>
23 <label {...labelProps} />
24 <input {...inputProps} />
25 </div>
26 );
27}
28
29// useDisclosure hook
30function useDisclosure() {
31 const id = useId();
32 const [isOpen, setIsOpen] = useState(false);
33
34 return {
35 isOpen,
36 open: () => setIsOpen(true),
37 close: () => setIsOpen(false),
38 toggle: () => setIsOpen((prev) => !prev),
39 triggerProps: {
40 'aria-expanded': isOpen,
41 'aria-controls': id,
42 onClick: () => setIsOpen((prev) => !prev),
43 },
44 contentProps: {
45 id,
46 hidden: !isOpen,
47 },
48 };
49}Best Practices#
Usage:
✓ Use for accessibility attributes
✓ Use for form label/input pairing
✓ Use for ARIA relationships
✓ Prefix for multiple IDs
Benefits:
✓ Stable across server/client
✓ Unique per component instance
✓ No ID collisions
✓ Works with SSR
Patterns:
✓ Create base ID, derive others
✓ Use in reusable components
✓ Combine with custom hooks
✓ Always pair labels with inputs
Avoid:
✗ Using for CSS selectors
✗ Using for keys in lists
✗ Relying on specific format
✗ Using outside React components
Conclusion#
The useId hook generates stable, unique IDs for accessibility attributes and form elements. Use it to properly associate labels with inputs, connect ARIA attributes, and build accessible reusable components. It ensures IDs match between server and client rendering.