Back to Blog
ReactHooksuseIdAccessibility

React useId Hook Guide

Master the React useId hook for generating unique IDs for accessibility attributes and form elements.

B
Bootspring Team
Engineering
January 14, 2020
5 min read

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>
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.

Share this article

Help spread the word about Bootspring