React useId: Why Math.random() Breaks Hydration
Math.random() IDs break hydration—server and client generate different values. useId creates stable, deterministic IDs based on component tree position.
Generate a unique ID with Math.random() or Date.now() in a React
component, and you've planted a hydration bomb. The server renders one ID, the client
generates another, and React throws a mismatch error. useId solves this—it
generates stable, unique IDs that match across server and client.
The Problem: Non-Deterministic IDs
// ❌ Hydration mismatch guaranteed
function Input({ label }) {
const id = `input-${Math.random().toString(36).slice(2)}`;
return (
<>
<label htmlFor={id}>{label}</label>
<input id={id} />
</>
);
}
When this renders on the server, Math.random() produces input-k7x9m.
During client hydration, it produces input-p2n4q. React sees the mismatch:
Warning: Prop `id` did not match. Server: "input-k7x9m" Client: "input-p2n4q"
Error: Hydration failed because the server rendered HTML didn't match the client.
The same happens with Date.now(), crypto.randomUUID(), or any
value that differs between server and client execution.
Why Counters Don't Work Either
// ❌ Also breaks in SSR
let counter = 0;
function Input({ label }) {
const id = `input-${counter++}`;
return (
<>
<label htmlFor={id}>{label}</label>
<input id={id} />
</>
);
}Counters seem deterministic, but they aren't across server/client boundaries. The server increments the counter, sends HTML, then the client's counter starts fresh at 0 again. Even if you reset it, the render order might differ between streaming SSR and client hydration.
The Solution: useId
import { useId } from 'react';
function Input({ label }) {
const id = useId();
return (
<>
<label htmlFor={id}>{label}</label>
<input id={id} />
</>
);
}
useId returns an ID like :r0: or :R1:. The exact
format is an implementation detail, but it's guaranteed to:
- Match between server render and client hydration
- Be unique within the React tree
- Be stable across re-renders (same component instance = same ID)
How useId Works
React assigns IDs based on the component's position in the React tree, not random values or global counters. The algorithm (simplified):
// Pseudocode for useId's approach
// Each component gets an ID based on its path in the tree
<App> // Path: ""
<Form> // Path: "0"
<Input /> // Path: "0.0" → ID: ":R0.0:"
<Input /> // Path: "0.1" → ID: ":R0.1:"
</Form>
<Form> // Path: "1"
<Input /> // Path: "1.0" → ID: ":R1.0:"
</Form>
</App>
Since the tree structure is identical on server and client (assuming proper hydration),
the IDs match. This is why useId must be called unconditionally—conditional
calls change the tree structure.
Multiple IDs from One Hook
Need multiple related IDs? Don't call useId multiple times—use one ID as a base:
function PasswordField() {
const id = useId();
return (
<>
<label htmlFor={`${id}-input`}>Password</label>
<input
id={`${id}-input`}
type="password"
aria-describedby={`${id}-hint ${id}-error`}
/>
<p id={`${id}-hint`}>Must be 8+ characters</p>
<p id={`${id}-error`} role="alert"></p>
</>
);
}
This generates :r0:-input, :r0:-hint, :r0:-error—all
from a single useId call.
ARIA and Accessibility Patterns
useId is essential for accessible component libraries. ARIA attributes like
aria-labelledby, aria-describedby, and aria-controls
require matching IDs:
function Accordion({ title, children }) {
const id = useId();
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button
aria-expanded={isOpen}
aria-controls={`${id}-panel`}
onClick={() => setIsOpen(!isOpen)}
>
{title}
</button>
<div
id={`${id}-panel`}
role="region"
aria-labelledby={`${id}-button`}
hidden={!isOpen}
>
{children}
</div>
</div>
);
}
function Dialog({ title, children, isOpen }) {
const id = useId();
return (
<dialog open={isOpen} aria-labelledby={`${id}-title`}>
<h2 id={`${id}-title`}>{title}</h2>
{children}
</dialog>
);
}When useId Still Breaks
Conditional Rendering Mismatch
// ❌ Different structure on server vs client
function App() {
const isClient = typeof window !== 'undefined';
return (
<div>
{isClient && <ClientOnlyComponent />} {/* Not on server */}
<Input label="Name" /> {/* Different tree position! */}
</div>
);
}
On the server, Input is at position 0. On the client, it's at position 1
(after ClientOnlyComponent). The IDs won't match.
Fix: Use suppressHydrationWarning on client-only wrappers,
or render a placeholder on the server that matches the client structure.
Dynamic Lists Without Keys
// ❌ List order changes between server and client
function List({ items }) {
return (
<ul>
{items.map((item) => (
<ListItem item={item} /> {/* Missing key! */}
))}
</ul>
);
}
function ListItem({ item }) {
const id = useId(); // ID depends on position, not item identity
return <li id={id}>{item.name}</li>;
}
Fix: Always use stable key props. If items can reorder,
the key ensures React tracks identity, and useId stays consistent.
Custom ID Prefix
For embedding React apps or micro-frontends, you may need custom prefixes to avoid ID collisions between multiple React roots:
// React 18+
import { createRoot } from 'react-dom/client';
const root = createRoot(document.getElementById('app'), {
identifierPrefix: 'app1-',
});
// IDs will be: "app1-:r0:", "app1-:r1:", etc.
For SSR, use the matching option in hydrateRoot:
import { hydrateRoot } from 'react-dom/client';
hydrateRoot(document.getElementById('app'), <App />, {
identifierPrefix: 'app1-',
});useId vs UUID Libraries
// ❌ Don't use for SSR
import { v4 as uuid } from 'uuid';
const id = uuid(); // Different on server vs client
// ❌ Don't use for SSR
const id = crypto.randomUUID(); // Same problem
// ✅ Use for client-only, non-hydrated IDs
const [id] = useState(() => crypto.randomUUID());
// Only runs on client, but causes hydration mismatch for initial render
// ✅ Best: useId for any server-rendered component
const id = useId();
UUID libraries are fine for IDs that don't appear in server-rendered HTML (like keys
for client-side-only lists), but for anything in the DOM during SSR, use useId.
Rules of useId
- Call at the top level—not in loops, conditions, or nested functions
- Don't use for list keys—it's not meant for that; use stable item IDs
- Don't parse or depend on the format—the
:r0:format is internal - One ID per concern—use suffixes for related IDs from one base
- Match identifierPrefix on server and client—if using custom prefixes
Key Takeaways
Math.random(),Date.now(), and counters cause hydration mismatches—don't use them for IDsuseIdgenerates deterministic IDs based on component tree position- Use one
useIdcall with suffixes for multiple related IDs - Essential for accessible components with ARIA attributes
- Conditional rendering that differs between server/client will still break—structure must match
Advertisement
Explore these curated resources to deepen your understanding
Official Documentation
Tools & Utilities
Related Insights
Explore related edge cases and patterns
Advertisement