EdgeCases Logo
Feb 2026
React
Deep
7 min read

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.

react
useid
hydration
ssr
nextjs
edge-case
accessibility

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 IDs
  • useId generates deterministic IDs based on component tree position
  • Use one useId call 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

Related Insights

Explore related edge cases and patterns

CSS
Deep
Font Preloading: When rel=preload Backfires
7 min
Next.js
Surface
Next.js 16: Dynamic by Default, Turbopack Stable, proxy.ts
8 min

Advertisement