I wasn’t expecting this post to be this long when writing it, but I think it reflects that it’s been a surprisingly thorny challenge of generating unique IDs for HTML elements in React. What looks trivial on the surface “just create a unique string” becomes a complex problem when you factor in (streaming) server-side rendering, (partial) hydration, and accessibility requirements. With React 18’s release, the team finally shipped an official solution: the useId() hook.
The Problem: Why IDs Matter
Let’s start with a common scenario. You’re building a form component that needs to associate labels with inputs:
function FormField() {
return (
<>
<label htmlFor="nameInput">Name:</label>
<input id="nameInput" type="text" />
</>
);
}
This works perfectly, until you use this component multiple times on the same page. Suddenly, you have duplicate IDs, violating HTML’s uniqueness requirement and breaking accessibility. Screen readers and assistive technologies rely on these connections between labels and form controls, so getting this right is not optional.
The Old Ways: Pre-React 18 Solutions
Before React 18, we had several approaches to this problem, each with significant drawbacks.
1. The Counter Approach
let idCounter = 0;
function FormField() {
const id = `field-${idCounter++}`;
return (
<>
<label htmlFor={id}>Name:</label>
<input id={id} type="text" />
</>
);
}
This works fine for client-side rendering in a predictable order, but breaks down with server-side rendering. If component rendering order differs between server and client (which is common in React), you’ll get hydration mismatches.
2. The UUID Approach
import { v4 as uuidv4 } from 'uuid';
function FormField() {
// Warning: This causes hydration mismatches!
const id = uuidv4();
return (
<>
<label htmlFor={id}>Name:</label>
<input id={id} type="text" />
</>
);
}
UUIDs are appealing because they’re guaranteed to be unique. However, they’re generated randomly, so the server and client will generate different IDs, causing hydration errors. Plus, they’re unnecessarily long for DOM IDs.
3. The useState Approach
function FormField() {
const [id] = useState(() => `field-${Math.random().toString(36).substr(2, 9)}`);
return (
<>
<label htmlFor={id}>Name:</label>
<input id={id} type="text" />
</>
);
}
Using useState with an initialization function helps ensure the ID is stable across renders, but we still have the server/client mismatch problem.
4. The Context Approach: React Aria’s SSR Solution
Libraries like React Aria implemented more sophisticated solutions using context providers. Here’s a simplified version of React Aria’s pre-React 18 implementation:
// Simplified version of React Aria's implementation
const SSRContext = createContext({
prefix: '',
current: 0
});
function SSRProvider({children}) {
const [isSSR, setIsSSR] = useState(true);
const parentContext = useContext(SSRContext);
const value = useMemo(() => ({
prefix: parentContext.prefix + (parentContext.current++).toString(36) + ':',
current: 0
}), [parentContext]);
// Switch to client rendering after mount
useLayoutEffect(() => {
setIsSSR(false);
}, []);
return (
<SSRContext.Provider value={value}>
{children}
</SSRContext.Provider>
);
}
function useSSRSafeId() {
const context = useContext(SSRContext);
const counter = useCounter(context); // Get and increment counter
return `id-${context.prefix}${counter}`;
}
This approach was one of the most robust solutions before React 18, but it had critical limitations:
- Relied on a context-based counter system that generated deterministic IDs based on the component’s position in the tree
- Required wrapping your application (or components) in an
SSRProvider - Used prefixes to handle nested contexts
- Attempted to maintain ID consistency across server and client
While this worked reasonably well in React 17 and earlier versions, it breaks down in React 18 under several conditions:
Suspense boundaries create rendering discontinuities: When two Suspense boundaries exist within the same context, they might hydrate in a different order than they were rendered on the server, disrupting the sequential ID generation.
Out-of-order rendering: If sibling components or parent components suspend during hydration, the rendering order shifts. React Aria’s counter-based approach assumes a stable rendering sequence, which React 18’s streaming and Suspense fundamentally break.
Partial hydration issues: When parts of the UI hydrate independently, the counter might increment differently on the server versus the client, leading to hydration mismatches or duplicate IDs.
Here’s a concrete example: Imagine two sibling components under an SSRProvider rendering on the server with IDs prefix-0 and prefix-1. On the client, if a Suspense boundary around the first component delays its hydration, the second component hydrates first and might reassign prefix-0, causing a clash when the first component eventually hydrates.
These issues couldn’t be solved in user space, they required deep integration with React’s rendering system.
Understand useId()
React 18 introduces useId(), a hook specifically designed to solve this problem:
import { useId } from 'react';
function FormField() {
const id = useId();
return (
<>
<label htmlFor={id}>Name:</label>
<input id={id} type="text" />
</>
);
}
That’s it. No more complex workarounds, no more SSR/hydration mismatches, no more accessibility issues.
The beauty of useId() is that it:
- Generates a stable ID that’s consistent across renders
- Ensures server-generated IDs match client-generated ones
- Works seamlessly with concurrent rendering and streaming SSR
- Doesn’t require external dependencies or context providers
How useId() Works Under the Hood
On the server, React maintains a counter for ID generation. When a component calls useId(), it increments this counter and returns a string with a special format (like :r0:, :r1:, etc.). The IDs are deterministic based on the component’s position in the React tree during server rendering.
During client-side hydration, React doesn’t regenerate these IDs. Instead, it preserves the server-generated IDs, avoiding mismatches. This works because the component tree structure should match between server and client.
The special case happens when an ID is first referenced during a regular client render (not hydration). In this scenario, React forces the useId() hook to “upgrade” to a client-generated ID, causing everything that references that ID to re-render with the new value. If some trees haven’t hydrated yet, React will hydrate them first before applying the update.
This approach handles the complexities of React 18’s streaming server renderer, where HTML can be delivered out-of-order. Solutions that worked in React 17 and earlier (like React Aria’s counter-based approach) break down in React 18 because you can no longer rely on a consistent rendering sequence.
Why React Aria’s Approach Couldn’t Survive React 18
React 18’s Streaming SSR fundamentally changes rendering order: With streaming SSR, React can send HTML in chunks based on Suspense boundaries. This means components might render in a completely different order than they appear in the final DOM, breaking any solution that relies on sequential rendering.
Suspense boundary interactions break deterministic IDs: Consider this scenario:
<SSRProvider> <Suspense fallback={<Spinner />}> <ComponentA /> {/* Generates id-0 on server */} </Suspense> <ComponentB /> {/* Generates id-1 on server */} </SSRProvider>If during client-side hydration,
ComponentAsuspends (e.g., waiting for data), what happens? In React 17, hydration would wait. But in React 18, React can continue hydratingComponentBwhileComponentAis suspended. Now your ID sequence is broken:ComponentBmight getid-0on the client while the server gave itid-1.No user-space solution could access React’s internal rendering queue: To solve this properly, a solution needs to understand React’s rendering sequence, Suspense boundaries, and hydration status, information only available to React internally. This is why
useId()needed to be built into React itself.
React Aria’s solution worked by wrapping Suspense boundaries in their own SSRProvider, creating isolated ID spaces, but even this approach couldn’t handle all the edge cases introduced by React 18’s concurrent features.
Creating Multiple Related IDs
An elegant feature of useId() is the ability to create multiple related IDs from a single base ID. Instead of calling useId() multiple times, you can append suffixes:
function NameFields() {
const id = useId();
return (
<div>
<label htmlFor={`${id}-firstName`}>First Name</label>
<div>
<input id={`${id}-firstName`} type="text" />
</div>
<label htmlFor={`${id}-lastName`}>Last Name</label>
<div>
<input id={`${id}-lastName`} type="text" />
</div>
</div>
);
}
This pattern scales beautifully for complex forms with many fields, while maintaining the hook rules (calling useId() just once per component).
Accessibility and ARIA Attributes
One of the most important use cases for useId() is supporting accessibility via ARIA attributes. Many ARIA attributes use ID references to connect elements:
function Tooltip() {
const id = useId();
return (
<>
<button aria-describedby={id}>Hover me</button>
<div id={id} role="tooltip" className="tooltip">
This is a tooltip
</div>
</>
);
}
The useId() hook properly handles all ID-based ARIA attributes, including those that accept multiple IDs as space-separated lists:
function ComplexLabel() {
const headingId = useId();
const descriptionId = useId();
return (
<>
<h2 id={headingId}>Account Settings</h2>
<p id={descriptionId}>Manage your account preferences</p>
<input aria-labelledby={`${headingId} ${descriptionId}`} />
</>
);
}
Why It’s Critical for React 18’s Streaming SSR
React 18 introduces a powerful new streaming server-side rendering implementation that leverages Suspense to deliver HTML in chunks. Instead of waiting for the entire page to render, React can send initial HTML quickly, then stream in more content as it becomes available.
This creates a fundamental problem for traditional ID generation approaches, including sophisticated ones like React Aria’s. Consider what happens in a streaming scenario:
- React begins rendering the page on the server
- A component suspends while waiting for data
- React sends HTML for already-rendered components to the client
- The suspended component resolves and gets rendered
- React sends this component’s HTML as a separate chunk
If you’re using a counter-based approach (even with contexts and prefixes like React Aria’s solution), the IDs generated might be completely different between server and client because:
- Components might render in different orders
- Suspense boundaries might resolve in different sequences
- Client hydration might proceed in chunks, rather than a single pass
There’s no optimal user-space solution for this problem. It requires deep integration with React’s rendering mechanism, which is exactly what useId() provides.
From useOpaqueIdentifier to useId()
The hook was first implemented as useOpaqueIdentifier in late 2019. This early version returned an opaque value: a string on the server, but a special object on the client that would warn if you tried to use its string value directly instead of passing it to a DOM attribute.
Based on community feedback, the React team changed the implementation to return a regular string, making it more flexible for real-world use cases. This change enables features like appending suffixes and using IDs in space-separated lists for ARIA attributes.
Migrations
If you’re upgrading to React 18 and want to adopt useId(), here’s how to migrate from common patterns:
From Counter-Based Approaches:
// Before
let counter = 0;
function Component() {
const id = `prefix-${counter++}`;
// ...
}
// After
function Component() {
const id = useId();
// You can still add your prefix if needed
const prefixedId = `prefix-${id}`;
// ...
}
From UUID or Random String Approaches:
// Before
function Component() {
const [id] = useState(() => `id-${Math.random().toString(36).slice(2)}`);
// ...
}
// After
function Component() {
const id = useId();
// ...
}
From React Aria or Similar Context-Based Solutions:
// Before
function Component() {
const id = useId(); // React Aria's useId
// ...
}
// After
// Remove the SSRProvider wrapper
function Component() {
const id = useId(); // React's built-in useId
// ...
}
If you’re using a library like React Aria that provides its own ID generation solution, check for updates that might leverage useId() internally. React Aria 3.15+ now uses React’s useId() when available, falling back to their previous implementation for compatibility with older React versions.
Real-World Impact
In my recent projects, switching to useId() eliminated several classes of bugs:
- Hydration errors in Next.js applications that were caused by ID mismatches
- Accessibility issues reported by automated testing tools
- Edge cases in complex forms with conditionally rendered fields
One particularly troublesome project involved a multi-step form with dynamically added form fields. With an old UUID approach, we would occasionally see flickering inputs and lost focus when a user was typing, all because IDs would change during rerenders. Switching to useId() fixed these issues.
Looking Beyond Form Controls
While form accessibility is the most common use case, useId() is valuable anywhere you need stable, unique identifiers. Here’s a detailed look at four key areas where useId() shines beyond simple form controls:
1. SVG Definitions with <defs> Elements
SVG elements often require unique identifiers, especially when using definitions that are referenced elsewhere in the markup. This is critical for maintaining proper references when components render multiple times:
function GradientButton() {
const gradientId = useId();
return (
<>
<svg width="0" height="0">
<defs>
<linearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#3366FF" />
<stop offset="100%" stopColor="#00CCFF" />
</linearGradient>
</defs>
</svg>
<button style={{ background: `url(#${gradientId})` }}>
Gradient Button
</button>
</>
);
}
Without stable IDs, SVG references can break during hydration or when multiple instances of a component render on the same page. This leads to missing gradients, patterns, or animations. The server/client consistency of useId() ensures SVG references remain intact across renders and hydration.
2. Modal Dialogs and Their Triggers
Modals require proper accessibility connections to their triggers, typically using ARIA attributes like aria-labelledby or aria-controls:
function AccessibleModal() {
const [isOpen, setIsOpen] = useState(false);
const headingId = useId();
const descriptionId = useId();
return (
<>
<button
onClick={() => setIsOpen(true)}
aria-controls={headingId}
>
Open Settings
</button>
{isOpen && (
<div
role="dialog"
aria-labelledby={headingId}
aria-describedby={descriptionId}
>
<h2 id={headingId}>Application Settings</h2>
<p id={descriptionId}>Configure your application preferences</p>
<button onClick={() => setIsOpen(false)}>Close</button>
</div>
)}
</>
);
}
These relationships are critical for screen readers to announce the modal’s purpose when it opens. The stable IDs provided by useId() ensure these connections work properly, especially in server-rendered applications where components might hydrate in varying orders.
3. Tooltip and Popover Relationships
Tooltips and popovers have similar accessibility requirements, often using aria-describedby to connect the tooltip content to its trigger:
function HelpTooltip({ text, children }) {
const [isVisible, setIsVisible] = useState(false);
const tooltipId = useId();
return (
<div className="tooltip-container">
<div
aria-describedby={isVisible ? tooltipId : undefined}
onMouseEnter={() => setIsVisible(true)}
onMouseLeave={() => setIsVisible(false)}
>
{children}
</div>
{isVisible && (
<div
id={tooltipId}
role="tooltip"
className="tooltip"
>
{text}
</div>
)}
</div>
);
}
With useId(), tooltip relationships remain stable even when components reorder or when server rendering streams HTML in chunks. This ensures screen readers can correctly associate tooltip content with the elements they describe.
4. Custom Component Libraries
Component libraries face unique challenges with ID generation, as they need to ensure unique IDs across potentially hundreds of instances of their components:
// In a component library
function Accordion() {
// Base ID for the accordion
const id = useId();
const [activePanel, setActivePanel] = useState(0);
const panels = [
{ title: "Section 1", content: "Content 1" },
{ title: "Section 2", content: "Content 2" }
];
return (
<div className="accordion">
{panels.map((panel, index) => {
const headerId = `${id}-header-${index}`;
const panelId = `${id}-panel-${index}`;
return (
<div key={index} className="accordion-item">
<h3>
<button
id={headerId}
aria-expanded={activePanel === index}
aria-controls={panelId}
onClick={() => setActivePanel(index)}
>
{panel.title}
</button>
</h3>
<div
id={panelId}
role="region"
aria-labelledby={headerId}
hidden={activePanel !== index}
>
{panel.content}
</div>
</div>
);
})}
</div>
);
}
A component library can generate consistent IDs across server and client renders, ensuring accessibility relationships work properly regardless of how many times a component is instantiated on a page.
Conclusion
The useId() hook in React 18 addresses specific challenges around generating stable IDs that are consistent between server and client rendering. This built-in solution resolves several technical issues that previous approaches couldn’t fully solve, particularly in the context of React 18’s streaming SSR and concurrent rendering features.
The hook’s implementation at the framework level provides access to React’s internal rendering system, enabling it to maintain ID consistency across various rendering scenarios that user-space solutions couldn’t reliably handle. This is particularly important for applications that use server-side rendering, where hydration mismatches can cause accessibility issues or visual inconsistencies.
The integration with React’s core rendering mechanism allows useId() to handle complex cases involving Suspense boundaries, out-of-order rendering, and partial hydration. For developers working with React 18, adopting useId() provides a standardized approach to ID generation that works reliably across rendering environments and component structures.