You’ve been there: your React app throws a warning during hydration about mismatched IDs. Maybe an aria-labelledby or htmlFor attribute points to an ID that doesn’t exist or changed between server and client. It’s subtle but frustrating , especially because IDs often feel like a straightforward detail.
Then you discover React’s useId hook, introduced to handle exactly this problem. But how does it actually work? And why does it matter so much for accessibility?
I recently dug into the React source and my own projects to understand what’s going on under the hood. Here’s what I found out.
The problem with IDs in React SSR apps
When you render React apps on the server and hydrate on the client, you want the server-generated HTML to match exactly what React renders on the client. If not, you get hydration warnings or worse, broken accessibility attributes.
IDs are a classic troublemaker. Imagine you have a component that generates an ID like id="input-42" on the server. On the client, if the ID changes to input-43 because the count or component instance order differs, your labels and ARIA attributes break.
Why does this happen?
- React components render multiple times during hydration and on the client.
- The order or number of hooks can differ subtly.
- Simple ways to generate IDs by incrementing a counter or using
Math.random() don’t guarantee stability.
This instability leads to mismatched IDs between server and client, causing warnings and accessibility bugs.
Enter React’s useId: stable, unique, and hydration-safe
React’s useId hook solves this by producing IDs that are:
- Stable across server and client renders.
- Unique to avoid collisions in the DOM.
- Predictable so React can keep track internally.
Here’s roughly how it does this:
On the server
React generates a unique prefix for the server render. This prefix is used to namespace all IDs generated by useId during that render. Each call to useId during server render increments a counter so IDs look like :r0:0, :r0:1, etc., where :r0: is the prefix.
On the client
React keeps an internal counter that matches the server’s ID generation order.
When useId is called, it returns an ID like :r0:0 again, making the client’s output match the server’s exactly.
Why the colon prefixes?
The colons are a convention React uses internally to mark IDs that were generated with useId. It helps React track and patch IDs if needed.
What if you render more components after hydration?
React’s internal counters continue counting on the client, so newly generated IDs after hydration will be unique but won’t collide with server-generated ones.
How useId helps with ARIA attributes
Many ARIA attributes need unique IDs to reference other elements, like aria-labelledby or aria-describedby. Without stable IDs, these relationships break on hydration.
By using useId, you ensure the ID references in labels and inputs match exactly between server and client, so screen readers get the correct associations.
For example:
function TextInput() {
const id = useId();
return (
<>
<label htmlFor={id}>Name</label>
<input id={id} />
</>
);
}
Here, the label’s htmlFor and the input’s id are kept in sync and stable across SSR and hydration.
Debugging hydration mismatches related to IDs
Sometimes you’ll still get warnings like:
Warning: Prop `id` did not match. Server: ":r0:0" Client: ":r1:0"
What’s going on?
- The server and client generated different ID prefix counters (
:r0: vs :r1:), meaning the client didn’t pick up the server’s prefix.
- This often happens if you conditionally render components or if the rendering order differs between server and client.
How to troubleshoot:
- Check component tree consistency: Make sure your components render the same number and order of
useId calls on server and client.
- Avoid conditional hooks: Don’t call
useId inside conditional blocks that might differ.
- Inspect the HTML: View the server HTML source to see what IDs were generated.
- Test hydration locally: Use React’s strict mode and hydration warnings to catch mismatches early.
What’s inside React’s useId implementation?
The React code keeps a global id counter per root and a unique root ID prefix. When you call useId, it returns a string combining the root prefix and the current counter, then increments the counter.
On server rendering, React generates a unique root prefix per render so multiple concurrent renders don’t clash.
This mechanism ensures that IDs are deterministic and stable.
When not to use useId
useId is great for accessibility and unique IDs but it’s not a general-purpose unique ID generator for everything:
- Don’t use it as a key in lists.
- Don’t use it for identifiers that must persist across sessions.
For those, you need stable IDs from your data or other sources.
Wrapping up
IDs can seem trivial until they break hydration or accessibility. React’s useId hook quietly solves this by coordinating a stable, unique ID generation strategy that works across server and client.
Next time you’re wiring up labels, descriptions, or any ARIA attribute that needs an ID, useId is your friend to avoid tricky bugs.
And if you hit hydration warnings related to IDs, remember to check your render consistency and that your useId calls are stable and unconditional.
Understanding what’s going on under the hood here can save you hours of debugging weird hydration issues and improve your app’s accessibility at the same time.