useId() in Action: Solving SSR Hydration Mismatches Without Refs
Learn how React’s `useId()` hook eliminates hydration errors in server-rendered forms and dynamic lists—no refs, no workarounds, just deterministic, stable IDs across SSR and client render.
BODY:
Why Hydration Mismatches Haunt Server-Rendered Forms
Server-Side Rendering (SSR) delivers fast initial page loads—but it comes with a subtle, frustrating pitfall: hydration mismatches. These occur when the HTML generated on the server doesn’t exactly match what React expects to find on the client during hydration. The most common culprit? Dynamic id attributes used for form labeling (<label htmlFor>), ARIA references (aria-labelledby, aria-describedby), or list item associations.
Before React 18, developers often resorted to fragile patterns: generating IDs with Math.random(), relying on useRef + useEffect to patch IDs post-hydration, or even skipping SSR for interactive sections. All introduce complexity, break accessibility, or delay interactivity.
Enter useId()—a lightweight, SSR-safe hook introduced in React 18.2 that guarantees stable, unique, and deterministic IDs across both server and client renders—without side effects, without refs, and without waiting for hydration.
In this article, we’ll walk through two real-world SSR scenarios—dynamic form fields and expandable lists—and show exactly how useId() resolves hydration mismatches before they happen.
The Problem: Dynamic Form Fields Break on Hydration
Imagine a settings page where users can add multiple email notification rules. Each rule has a toggle, a label, and an optional description field:
function EmailRule({ index }: { index: number }) {
const [enabled, setEnabled] = useState(true);
const id = `email-rule-${index}-toggle`;
return (
<div className="rule">
<label htmlFor={id} className="rule-label">
Enable notifications for rule #{index + 1}
</label>
<input
id={id}
type="checkbox"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
{enabled && (
<p className="rule-desc">We’ll send alerts when new emails arrive.</p>
)}
</div>
);
}
function NotificationSettings() {
const [rules, setRules] = useState([{ id: 1 }, { id: 2 }]);
return (
<form>
{rules.map((_, i) => (
<EmailRule key={i} index={i} />
))}
</form>
);
}This works fine in CSR—but under SSR, it fails silently. Why?
On the server, index is stable: [0, 1]. So IDs become email-rule-0-toggle and email-rule-1-toggle.
But on the client, React re-renders before hydration completes, and if any state changes (e.g., due to a useEffect firing early, or a race in hydration order), index may temporarily shift—or worse, React may assign different indices during reconciliation. Worse still: if you later insert a rule at index 0, all subsequent indices shift, invalidating every htmlFor reference.
The result? A hydration warning like:
Warning: Expected server HTML to contain a matching <input> in <label>.And more critically: screen readers lose the label-to-input association. Accessibility breaks.
The Fix: useId() Generates Stable, Scoped IDs
useId() solves this by returning a string that’s guaranteed to be identical on the server and client—derived from React’s internal component position in the tree—not from props, state, or timing.
Here’s the corrected version:
import { useId } from 'react';
function EmailRule({ index }: { index: number }) {
const id = useId(); // ✅ Stable across SSR & client
return (
<div className="rule">
<label htmlFor={id} className="rule-label">
Enable notifications for rule #{index + 1}
</label>
<input
id={id}
type="checkbox"
defaultChecked={true} // Use defaultChecked/defaultValue for SSR
/>
<p className="rule-desc">We’ll send alerts when new emails arrive.</p>
</div>
);
}Note two key changes:
useId()replaces the brittleindex-based ID.- We use
defaultCheckedinstead ofchecked+useState, becauseuseStatevalues are not available on the server. UsingdefaultCheckedensures the server renders the same checked state the client expects.
Now, no matter how many times the component re-renders or how the list mutates, id remains constant per instance—and matches exactly between server and client.
Bonus: Composing IDs for Clarity
You can safely prefix useId() outputs for readability and debugging—React handles uniqueness internally:
const baseId = useId();
const inputId = `${baseId}-input`;
const labelId = `${baseId}-label`;
const descId = `${baseId}-description`;
return (
<div>
<label id={labelId} htmlFor={inputId}>...</label>
<input id={inputId} aria-describedby={descId} />
<p id={descId}>...</p>
</div>
);This pattern scales cleanly to complex forms with nested fields, error messages, and live regions.
Real-World Example: Expandable FAQ List
Let’s level up: a server-rendered FAQ section where each question expands its answer on click. This requires aria-expanded, aria-controls, and unique IDs for every question/answer pair.
❌ Broken SSR Version (ID collision risk)
function FAQItem({ question, answer }: { question: string; answer: string }) {
const [expanded, setExpanded] = useState(false);
const id = `faq-${Math.random().toString(36).substr(2, 9)}`; // 🚫 Never do this
return (
<div className="faq-item">
<button
aria-expanded={expanded}
aria-controls={`${id}-answer`}
onClick={() => setExpanded(!expanded)}
>
{question}
</button>
<div id={`${id}-answer`} hidden={!expanded}>
{answer}
</div>
</div>
);
}Math.random() produces different values on server vs. client → aria-controls points to a non-existent ID → hydration mismatch + broken ARIA.
✅ Fixed with useId()
import { useId, useState } from 'react';
function FAQItem({ question, answer }: { question: string; answer: string }) {
const id = useId(); // ✅ Same ID on server and client
const [expanded, setExpanded] = useState(false);
return (
<div className="faq-item">
<button
aria-expanded={expanded}
aria-controls={`${id}-answer`}
onClick={() => setExpanded(!expanded)}
>
{question}
</button>
<div
id={`${id}-answer`}
role="region"
aria-labelledby={id}
hidden={!expanded}
>
{answer}
</div>
</div>
);
}
function FAQSection() {
const faqs = [
{ question: "How do I reset my password?", answer: "Visit login.example.com/forgot..." },
{ question: "Is my data encrypted?", answer: "Yes, end-to-end encryption is enabled by default." }
];
return (
<section aria-labelledby="faq-heading">
<h2 id="faq-heading">Frequently Asked Questions</h2>
{faqs.map((faq, i) => (
<FAQItem key={i} {...faq} />
))}
</section>
);
}✅ No hydration warnings.
✅ Screen readers announce “expanded/collapsed” correctly.
✅ Works with defaultOpen logic (e.g., expanding the first item server-side via useState(true) if you hydrate with the same initial state).
How useId() Actually Works (No Magic, Just Coordination)
useId() doesn’t generate random strings or rely on global counters. Instead:
- On the server, React assigns incrementing numeric IDs based on component depth and order in the render tree.
- On the client, React replays that same assignment sequence during hydration—using the same algorithm and context.
- The output is a string like
:r1:,:r2:, or (with prefixes)my-app:123:input.
Because it’s derived from static component structure—not runtime values—it’s fully deterministic. No effect, no cleanup, no dependency array.
When NOT to Use useId()
useId() shines for static, structural IDs: labels, ARIA targets, form fields, accordions, tabs.
Avoid it for:
- Dynamic content IDs tied to data: If you need an ID that must match a database record (e.g.,
<div id={user-${user.id}}>), stick with your data ID—it’s already stable and meaningful. - CSS-in-JS or styling hooks:
useId()isn’t needed forclassNameorstyle; those don’t affect hydration. - Client-only components: If you’re rendering entirely on the client (e.g., modals triggered by
useEffect),useId()still works—butcrypto.randomUUID()is simpler and equally safe.
Best Practices Recap
- Prefer
useId()over index-based or random IDs for anyid,htmlFor,aria-*attribute. - Use
defaultChecked/defaultValueinstead ofchecked/value+useStatefor SSR-critical form inputs. - Prefix IDs meaningfully (
${id}-label,${id}-error)—it helps debug and doesn’t break stability. - Test hydration with React DevTools: Toggle “Highlight updates” and watch for mismatch warnings.
- Combine with
useTransitionfor async inserts: When adding new items to a list, wrap the state update to avoid tearing whileuseId()keeps IDs consistent.
Final Thought: Simpler, Safer, More Accessible
useId() isn’t flashy—but it removes an entire class of subtle, hard-to-debug SSR bugs. It makes dynamic forms and interactive lists just work out of the box, with zero ref hacks, zero hydration delays, and full accessibility compliance.
In a world where React Server Components and streaming SSR are becoming mainstream, useId() is one of the quiet foundations enabling robust, resilient, inclusive web apps.
So next time you reach for Math.random() or useRef to solve an ID problem—pause. Reach for useId() instead. Your hydration logs (and your screen reader users) will thank you.


