Stable IDs for Accessibility Attributes
Accessibility in React components often fails on one detail: IDs for aria-labelledby and htmlFor must be unique and must match between server and client. useId solves both at once, without boilerplate and without hydration errors.
Table of Contents
- 1. The ID Problem in React Components
- 2. Why ARIA IDs Must Be Unique
- 3. useId: Syntax and Basic Behavior
- 4. useId in Form Components
- 5. Multiple IDs from a Single useId Call
- 6. SSR and Hydration: Why useId Is Safe
- 7. useId vs. Other ID Approaches Compared
- 8. Common Mistakes and Limitations
- 9. TypeScript and Component Design
- 10. Summary
- 11. FAQ
1. The ID Problem in React Components
In HTML, IDs connect elements to each other: a <label> element references an <input> element via htmlFor and id. ARIA attributes such as aria-labelledby, aria-describedby and aria-controls reference other elements via their IDs. These connections are fundamental for accessibility, screen readers use them to navigate through forms and understand which label belongs to which input field.
In React, this creates a problem: if the same component is rendered multiple times, all instances would have the same static ID. A label with htmlFor="email" and an input with id="email", with two instances of the component that produces four elements with duplicate IDs. Browser behavior with duplicate IDs is undefined: some browsers pick the first element, some pick the last, some ignore the connection entirely. Screen reader behavior is even less predictable.
The classic "solution" was a global counter (let idCounter = 0; const nextId = () => \`id-\${idCounter++}\`) or passing IDs down as props. Both approaches have problems: a global counter is not SSR-safe, on the server a new render happens on every request, but the counter keeps accumulating. That leads to hydration mismatches because server IDs and client IDs no longer match. Passing props is boilerplate and pushes the responsibility onto the caller. useId solves both problems natively.
2. Why ARIA IDs Must Be Unique
The ARIA specification defines that IDs referenced in ARIA attributes must be unique across the entire document. This is not a recommendation, it is a technical requirement: the accessibility tree used by screen readers and other assistive technologies is built by the browser from the DOM. If an ID occurs more than once in the document, it is unclear which element the reference actually points to.
All attributes that use ID references are affected: aria-labelledby (the label of an element), aria-describedby (description or hint), aria-controls (controlled elements, e.g. an accordion panel), aria-owns (ownership of elements that sit elsewhere in the DOM structure) and htmlFor (label-to-input connection). All of these references assume unique IDs. In a React application built from reusable components, that is hard to get right without a systematic solution.
A concrete scenario: a design system component FormField encapsulates a label, an input and an error message. The error message should be connected to the input via aria-describedby. If FormField is used five times in one form, every instance needs its own, unique ID for the input and the error message. useId generates these IDs automatically and correctly.
3. useId: Syntax and Basic Behavior
useId takes no parameters and returns a string. That string is unique for every component instance, stable across re-renders, and matches between server-side and client-side rendering. The format of the generated ID is implementation-dependent (e.g. :r0:, :r1:, and so on) and should never be parsed or predicted. Important: the generated IDs are meant for HTML attributes, not as keys in lists (the key prop) and not as database keys.
Internally, the hook uses the component's positional index in the render tree, combined with the React root index (when there are multiple React roots on one page). That produces deterministic IDs that match between server and client, the basic prerequisite for hydration without a mismatch. With Concurrent Mode and streaming SSR, useId works correctly because the ID generation is tied to the component's position in the tree, not to the timing of the render.
import { useId } from 'react';
// Basic usage, one useId call per component instance
function EmailField() {
// Unique, stable ID for this specific instance
const id = useId();
return (
<div>
{/* htmlFor connects label to input via shared id */}
<label htmlFor={id}>Email address</label>
<input
id={id}
type="email"
name="email"
autoComplete="email"
aria-required="true"
/>
</div>
);
}
// Multiple instances get different IDs automatically
export function NewsletterForm() {
return (
<form>
{/* Each EmailField instance gets its own unique ID */}
<EmailField />
{/* A second instance would get a different ID, no collision */}
</form>
);
}
// Prefix pattern, derive multiple related IDs from one useId call
function SearchField() {
const baseId = useId();
const inputId = `${baseId}-input`;
const hintId = `${baseId}-hint`;
const errorId = `${baseId}-error`;
return (
<div>
<label htmlFor={inputId}>Search</label>
<input
id={inputId}
type="search"
aria-describedby={`${hintId} ${errorId}`}
/>
<p id={hintId} className="text-sm text-slate-500">
Enter at least 3 characters
</p>
<p id={errorId} className="text-sm text-red-600" role="alert">
{/* Error message rendered here when validation fails */}
</p>
</div>
);
}
4. useId in Form Components
Form components are the most common use case for useId. A complete form field component typically consists of a label, an input, an optional hint text and an error message. For a correct accessibility implementation, the label and input must be connected via htmlFor/id, and the input must reference the hint and the error message via aria-describedby. That means at least two different IDs per component instance.
The recommended pattern: a single useId call per component, and then all required IDs are derived by appending suffixes. const id = useId(); const inputId = \`\${id}-input\`; const errorId = \`\${id}-error\`. This pattern works reliably because the base ID is unique and all derived IDs are therefore unique as well. It is also maintenance-friendly: whenever a new element that needs an ID is added, another suffix is simply derived from the base.
An important nuance about aria-describedby: the attribute accepts a space-separated list of IDs. That makes it possible to reference the hint text and the error message at the same time: aria-describedby={\`\${hintId} \${errorId}\`}. Screen readers read both texts aloud, the hint first, then the error. That is an important pattern for accessible forms, and it does not work without correct, unique IDs.
5. Multiple IDs from a Single useId Call
The suffix pattern, where every ID of a component is derived from a single base ID, is the recommended approach. It has a practical advantage over calling useId multiple times: all IDs of one component share the same base prefix, which makes it immediately visible in the DOM which elements belong together while debugging. If you see :r3:-input, :r3:-hint and :r3:-error in the DOM, it is clear that they come from the same component instance.
Calling useId multiple times in the same component is technically possible and correct, every call generates a new, unique ID. But those IDs are produced without any obvious relationship to each other. For debugging and code readability, the suffix pattern is the better choice. The only case where multiple useId calls make sense is when a component conceptually contains independent parts that have no relationship to each other and each need their own ID namespace.
When using useId in custom hook libraries, the hook should be moved into the custom hook itself, not into the component that calls the custom hook. That fully encapsulates the ID generation inside the hook and keeps the component that calls the hook simpler. A custom hook useFormField could call useId internally and return the finished, formatted IDs and props to the outside.
import { useId } from 'react';
interface FormFieldProps {
label: string;
hint?: string;
error?: string;
type?: string;
name: string;
required?: boolean;
}
// Encapsulates all ID logic, caller never manages IDs
function FormField({
label,
hint,
error,
type = 'text',
name,
required = false,
}: FormFieldProps) {
// Single useId call, all related IDs derived from one base
const baseId = useId();
const inputId = `${baseId}-input`;
const hintId = `${baseId}-hint`;
const errorId = `${baseId}-error`;
// Build aria-describedby from available descriptors
const describedBy = [hint && hintId, error && errorId]
.filter(Boolean)
.join(' ');
return (
<div className="space-y-1">
<label htmlFor={inputId} className="block text-sm font-medium text-slate-700">
{label}
{required && <span aria-hidden="true" className="text-red-500 ml-1">*</span>}
</label>
<input
id={inputId}
type={type}
name={name}
required={required}
aria-required={required}
aria-describedby={describedBy || undefined}
aria-invalid={error ? 'true' : undefined}
className={`w-full rounded-lg border px-3 py-2 ${
error ? 'border-red-400 bg-red-50' : 'border-slate-300'
}`}
/>
{hint && (
<p id={hintId} className="text-xs text-slate-500">
{hint}
</p>
)}
{error && (
<p id={errorId} className="text-xs text-red-600" role="alert">
{error}
</p>
)}
</div>
);
}
// Usage, no ID management needed by the caller
export function RegistrationForm() {
return (
<form className="space-y-4">
{/* Each instance gets unique IDs automatically */}
<FormField name="email" label="Email" type="email" required hint="We never send marketing emails" />
<FormField name="username" label="Username" required error="Username already taken" />
<FormField name="password" label="Password" type="password" required hint="At least 12 characters" />
</form>
);
}
6. SSR and Hydration: Why useId Is Safe
The fundamental problem with IDs in SSR applications is the hydration mismatch. If the server renders HTML with certain IDs and the client generates different IDs during hydration, a mismatch occurs. React then warns in the console and tries to repair the DOM, which can lead to visual glitches. A global ID counter is SSR-unsafe because the counter value can differ between server and client (on the client, other components may already have been initialized, for example).
useId solves this problem through deterministic generation based on the component's position in the render tree. Because the render tree position is identical on server and client (same component code, same props), the generated IDs are identical as well. React can compare the server HTML snapshot with the client render and finds matching IDs, no mismatch, no DOM repair, no visual glitch.
In React applications with multiple React roots (for example when React is embedded into an existing page), the root index is factored into the ID generation. That ensures two React roots on the same page never have ID collisions. The identifierPrefix attribute of the createRoot function also makes it possible to set a manual prefix, useful when multiple React apps run on one page and IDs must not collide.
7. useId vs. Other ID Approaches Compared
Before useId existed, developers used various workarounds. Each one has specific weaknesses that useId elegantly solves.
| Approach | SSR-safe | Concurrent Mode | No boilerplate |
|---|---|---|---|
| Global counter | No, mismatch | No | Yes |
| useState + useEffect | No, mismatch | No | No, lots of code |
| ID as prop | Yes | Yes | No, prop drilling |
| Math.random() | No, mismatch | No | Yes |
| useId | Yes, deterministic | Yes | Yes, one hook call |
The useState+useEffect approach was a widespread workaround: on the server, useState('') returns an empty ID, and useEffect sets the ID on the client. That prevents the mismatch, but it means labels and inputs are unconnected on the server. Screen readers see the initial server HTML without ID connections, which is also problematic for SEO with crawlers that do not execute JavaScript. useId does not have this problem: the IDs are already set correctly in the server HTML.
8. Common Mistakes and Limitations
The most common mistake when using useId: using the generated ID as a key for list elements. The React documentation is explicit here: useId is not meant for key props. Keys in lists should come from the data itself (for example database keys, UUIDs from an API). A generated ID would be new on every render and would break React's reconciliation behavior.
A second common mistake: calling useId outside components or custom hooks. Like every React hook, useId may only be called inside components or custom hooks, not in event handlers, not inside useMemo or useCallback callbacks, not outside of rendering. This restriction applies to all hooks and is checked by React lint rules.
A third mistake concerns the assumption that the format of the generated ID is stable. The format can change between React versions. Anyone who relies on the format in tests, CSS selectors or JavaScript queries is writing fragile code. The ID should only be used as a value for HTML attributes, never as a query selector or for any other purpose. For testing, it is recommended to find elements by accessible properties (such as role, label text) rather than by ID.
import { useId } from 'react';
// WRONG: useId used as list key, IDs are not stable across renders for list reconciliation
function WrongList({ items }: { items: string[] }) {
const id = useId();
return (
<ul>
{items.map((item, index) => (
// WRONG: Don't use useId for list keys, use item data or index
<li key={`${id}-${index}`}>{item}</li>
))}
</ul>
);
}
// RIGHT: List keys from data, useId only for ARIA attributes
function RightList({ items }: { items: { id: string; label: string }[] }) {
const listId = useId();
return (
<ul id={listId} role="listbox" aria-label="Selection options">
{items.map((item) => (
// Data-driven key, not related to useId
<li key={item.id} role="option" aria-selected={false}>
{item.label}
</li>
))}
</ul>
);
}
// RIGHT: Custom hook encapsulates ID logic
function useFormField(name: string) {
const baseId = useId();
return {
inputProps: {
id: `${baseId}-input`,
name,
'aria-describedby': `${baseId}-hint ${baseId}-error`,
},
labelProps: { htmlFor: `${baseId}-input` },
hintProps: { id: `${baseId}-hint` },
errorProps: { id: `${baseId}-error`, role: 'alert' as const },
};
}
// Clean component, no manual ID management
function PasswordField() {
const field = useFormField('password');
return (
<div>
<label {...field.labelProps}>Password</label>
<input type="password" {...field.inputProps} />
<p {...field.hintProps}>At least 12 characters</p>
<p {...field.errorProps}>{/* error message */}</p>
</div>
);
}
9. TypeScript and Component Design
useId always returns a string and has no type parameters. The TypeScript integration is trivial, but there are important design decisions when using it inside component libraries. A well-designed component that uses useId internally should not need to expose an id prop to the outside, the ID generation is an implementation detail of the component. If an external ID prop is still needed (for example for e2e tests or CSS), it should be optional and able to override the internally generated ID.
The custom hook pattern is especially valuable for design system libraries. A useFormField hook that calls useId internally and returns finished props objects to the outside makes form components as simple as possible to use. The caller distributes the props objects onto the corresponding elements via the spread operator, without ever seeing or managing an ID. That is the cleanest API design for accessible form components.
When testing with React Testing Library, it is important not to find elements by generated IDs. getByRole('textbox', { name: 'Email address' }) is more robust and more accessible than getById, because it tests the correct label connection. A test that successfully finds an element via getByRole with a label text confirms that the htmlFor/id connection is correct, exactly what useId makes possible.
10. Summary
useId is a small hook with a large impact on accessibility. It generates unique, stable IDs for every component instance, deterministic on server and client, without global counters, without props, without useEffect. That turns correct ARIA connections in reusable components into a trivial task instead of a boilerplate problem.
The recommended pattern: one useId call per component (or custom hook), all required IDs derived through suffixes. Do not use it for list keys. Do not rely on the format of the ID. Ideally encapsulate the hook in a custom hook that returns finished props objects. And in tests, find elements by role and label text rather than by ID. Anyone who follows these four points implements accessible React components that work correctly in SSR environments and never have ID collisions when used multiple times.
useId: The Essentials at a Glance
What for
Unique IDs for aria-labelledby, aria-describedby, aria-controls and htmlFor, correct ARIA without ID collisions in components used multiple times.
SSR-safe
Deterministic based on the position in the render tree, server and client generate identical IDs, no hydration mismatch.
Suffix pattern
One useId call per component, derive every ID as a suffix: ${baseId}-input, ${baseId}-hint, ${baseId}-error.
Not for list keys
useId is not meant for key props in lists. Keys should come from data. useId is meant exclusively for HTML attributes.