Accessible and Collision-Free Component IDs with useId in React
Building accessible web applications requires connecting DOM elements using HTML id attributes. Whether you are linking a <label> to an <input> via htmlFor, or associating helper text and validation errors with form controls using aria-describedby and aria-labelledby, unique identifiers are an absolute necessity.
In traditional client-only React, developers often generated IDs using global incrementing counters or Math.random(). However, in modern Server-Side Rendered (SSR) environments and concurrent streaming architectures, these techniques lead to severe hydration mismatches and out-of-order ID collisions.
To provide a robust, deterministic solution, React provides the useId Hook (React documentation). In this guide, we examine why legacy ID generation fails during SSR, how useId constructs collision-free IDs, the multi-ID suffix pattern, and critical anti-patterns to avoid.
Why Legacy ID Generation Breaks in SSR
When rendering components both on the server and on the client, HTML IDs must match identically across both environments.
The Failure of Math.random()
// ❌ FAILS: Server and client generate different random numbers
function BadInput() {
const id = `input-${Math.random()}`; // Hydration mismatch!
return <input id={id} />;
}
The server emits <input id="input-0.4289">, while the client hydrates with <input id="input-0.9123">. React immediately flags a hydration mismatch.
The Failure of Global Incrementing Counters
// ❌ FAILS: Counter increments out of order in concurrent / streaming SSR
let globalCount = 0;
function BadCounterInput() {
const id = `input-${globalCount++}`;
return <input id={id} />;
}
In server environments handling multiple concurrent user requests or streaming components out-of-order via <Suspense>, the order in which components render is non-deterministic. A component that rendered 3rd on the server might render 1st during client hydration, resulting in mismatched IDs and broken accessibility relationships.
How useId Works Under the Hood
useId is a built-in Hook that generates a unique, stable string ID for the calling component:
import { useId } from 'react';
function FormField() {
const id = useId();
// id will look like ":r0:", ":r1:", etc.
}
Instead of relying on global counters or random numbers, useId derives IDs from the component’s position within the React fiber tree.
Because the tree hierarchy is identical on the server and the client during initial hydration, React guarantees that useId generates the exact same string in both environments. The colons (:) in the generated string (e.g. :r0:) serve a dual purpose: they make the ID unique and ensure it is not easily confused with standard CSS class names or query selectors.
Best Practice: Generating Multiple Related IDs with Suffixes
A common mistake is calling useId multiple times within the same component to generate IDs for different fields:
// ⚠️ UNNECESSARY: Multiple hook invocations
function UserForm() {
const firstNameId = useId();
const lastNameId = useId();
const emailId = useId();
// ...
}
The recommended pattern is to call useId once at the component level, and append distinct string suffixes to derive all necessary IDs:
'use client';
import { useId } from 'react';
export function AccessibleTextField({
label,
hint,
error,
}: {
label: string;
hint?: string;
error?: string;
}) {
const id = useId();
const inputId = `${id}-input`;
const hintId = `${id}-hint`;
const errorId = `${id}-error`;
const ariaDescribedBy = [
hint ? hintId : null,
error ? errorId : null,
].filter(Boolean).join(' ') || undefined;
return (
<div className="field-group">
<label htmlFor={inputId}>{label}</label>
<input
id={inputId}
aria-describedby={ariaDescribedBy}
aria-invalid={Boolean(error)}
/>
{hint && (
<p id={hintId} className="field-hint">
{hint}
</p>
)}
{error && (
<p id={errorId} className="field-error">
{error}
</p>
)}
</div>
);
}
This ensures that screen readers accurately announce both the label, helper hint, and validation error when the user focuses the input, all while maintaining clean, maintainable code.
Multi-Root Applications and identifierPrefix
If your application mounts multiple independent React root instances on the same HTML document (for example, in micro-frontend architectures, or widgets embedded into an existing legacy CMS), IDs generated by separate roots might theoretically collide.
React solves this by providing the identifierPrefix option in createRoot (React documentation) and hydrateRoot (React documentation):
// Mounting Root A
const rootA = createRoot(document.getElementById('header-widget')!, {
identifierPrefix: 'header-',
});
rootA.render(<HeaderWidget />);
// Mounting Root B
const rootB = createRoot(document.getElementById('comments-widget')!, {
identifierPrefix: 'comments-',
});
rootB.render(<CommentsWidget />);
All IDs generated by useId inside HeaderWidget will start with :header-r...:, completely eliminating cross-app collisions on the page.
Critical Anti-Pattern: Never Use useId for List Keys
A frequent misuse of useId is attempting to generate key props for items rendered from an array:
// ❌ WRONG: Never use useId to generate list keys!
function TodoList({ items }: { items: string[] }) {
return (
<ul>
{items.map((item) => {
const id = useId(); // VIOLATION of Rules of Hooks (called in loop)!
return <li key={id}>{item}</li>;
})}
</ul>
);
}
- Violates Rules of Hooks: Calling
useIdinside a.map()callback violates the fundamental rule that Hooks cannot be called inside loops (React documentation). - Breaks Reconciliation: Keys must be stable, unique identifiers derived directly from your data (like a database ID), not generated by React during rendering. If the array items reorder, tree-position-based IDs will cause React to mismatch DOM state.
Conclusion
useId is the definitive solution for managing DOM IDs in modern React applications. By tying ID generation to tree structure, it guarantees consistency between server HTML and client hydration, simplifies complex accessibility relationships (aria-describedby), and prevents collisions across multi-root applications.