Rreact.wiki
← Blog

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:

TSX
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:

TSX
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:

TSX
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 brittle index-based ID.
  • We use defaultChecked instead of checked + useState, because useState values are not available on the server. Using defaultChecked ensures 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:

TSX
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)

TSX
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()

TSX
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 for className or style; 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—but crypto.randomUUID() is simpler and equally safe.

Best Practices Recap

  1. Prefer useId() over index-based or random IDs for any id, htmlFor, aria-* attribute.
  2. Use defaultChecked / defaultValue instead of checked/value + useState for SSR-critical form inputs.
  3. Prefix IDs meaningfully (${id}-label, ${id}-error)—it helps debug and doesn’t break stability.
  4. Test hydration with React DevTools: Toggle “Highlight updates” and watch for mismatch warnings.
  5. Combine with useTransition for async inserts: When adding new items to a list, wrap the state update to avoid tearing while useId() 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.