Skip to content
jsmanifest logojsmanifest

React useId in 2026: Why It Beats Math.random() for Accessibility and SSR Hydration

React useId in 2026: Why It Beats Math.random() for Accessibility and SSR Hydration

Learn why React's useId Hook solves SSR hydration mismatches and accessibility problems that Math.random() and crypto.randomUUID() cannot fix. Includes production patterns and common pitfalls.

Most React developers reach for Math.random() when they need a unique ID. The component renders, the form field gets an ID, the label connects, and everything works. Ship it.

Then the production SSR logs fill with hydration warnings. Screen readers stop announcing field relationships. The client DOM re-renders on every page load, flickering inputs and breaking focus states. Users file accessibility bugs, and the team scrambles to find the root cause.

flowchart LR
    A("Component renders")
    B("Math.random() generates ID")
    C("Server outputs ID abc123")
    D("Client generates ID xyz789")
    E("Hydration mismatch warning")
    style E stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7

The problem is not random IDs themselves. The problem is that Math.random() runs twice in server-side rendering: once on the server, once on the client. Two separate JavaScript runtimes produce two different values for the same component instance. React expects the HTML to match between renders, discovers a mismatch, and throws a hydration error.

React's useId Hook (introduced in React 18.0.0, documented at react.dev/reference/react/useId) solves this by generating deterministic IDs that match across server and client boundaries. The server emits ":r1:", the client hydrates with ":r1:", and the DOM stays intact.

flowchart LR
    A("Component renders")
    B("useId generates ID")
    C("Server outputs :r1:")
    D("Client hydrates :r1:")
    E("DOM stable, no warnings")
    style E stroke:#34d399,fill:#0b3b2e,color:#d1fae5

This post explains the technical mechanism, shows production patterns for accessible forms, compares useId against alternatives, and documents the edge cases where developers should avoid it entirely.

Key Takeaways

  • useId generates stable IDs that match between server and client renders, eliminating SSR hydration mismatches.
  • Random ID generators (Math.random(), crypto.randomUUID()) break hydration because they produce different values on each runtime.
  • The Hook creates accessibility relationships (aria-describedby, aria-labelledby) that persist across renders without causing warnings.
  • Developers should never use useId for React list keys or cache keys (both require data-derived identifiers, not component-instance IDs).
  • Production components can call useId multiple times or concatenate a shared prefix to generate related IDs for complex form patterns.

The SSR Hydration Mismatch Problem

Server-side rendering sends HTML to the browser before JavaScript loads. React then hydrates that HTML by attaching event handlers and verifying the DOM structure matches what the component would render on the client.

When an ID changes between server and client renders, React logs a warning and replaces the server HTML with a new client render. This destroys form state, breaks focus, and forces a layout shift.

The sequence happens in milliseconds but produces user-visible defects. A password field loses focus. A screen reader announces the wrong hint text. The browser re-paints the form, causing a flicker.

// This breaks SSR hydration
function BrokenPasswordField() {
  const hintId = `hint-${Math.random().toString(36).slice(2)}`;
  
  return (
    <>
      <label>
        Password:
        <input type="password" aria-describedby={hintId} />
      </label>
      <p id={hintId}>Must contain at least 18 characters</p>
    </>
  );
}

The server renders hint-abc123. The client renders hint-xyz789. React sees the mismatch in the id and aria-describedby attributes, discards the server HTML, and re-renders from scratch on the client. Console logs show:

Warning: Prop `id` did not match. Server: "hint-abc123" Client: "hint-xyz789"

The root issue is that Math.random() is non-deterministic across JavaScript runtimes. Node.js (server) and the browser (client) use different random number generators seeded differently. Even if they used the same algorithm, there is no guarantee the sequence would align because other code might consume random values between component renders.

flowchart TD
    A("Server: Node.js runtime")
    B("Math.random() -> 0.4231")
    C("ID: hint-abc123")
    D("Client: Browser runtime")
    E("Math.random() -> 0.8756")
    F("ID: hint-xyz789")
    G("React compares IDs")
    H("Mismatch detected")
    I("Re-render forces DOM replacement")
    
    A --> B --> C
    D --> E --> F
    C --> G
    F --> G
    G --> H --> I
    
    style H stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
    style I stroke:#ef4444,fill:#450a0a,color:#fca5a5

Developers sometimes attempt to fix this by moving the ID generation into useEffect, which only runs on the client. This eliminates the hydration mismatch but introduces a worse problem: the server HTML ships without any id attribute, so the aria-describedby reference points to nothing. Screen readers cannot find the hint text. The component works for sighted users but fails accessibility requirements.

Diagram showing SSR hydration flow with mismatched IDs

How useId Generates Stable IDs Across Server and Client

React's useId Hook produces IDs based on the component's position in the tree, not on random values or execution order. The official React 18 release notes describe the mechanism: "Inside React, useId is generated from the 'parent path' of the calling component."

Each component in the React tree has an implicit path from the root. When a component calls useId, React concatenates the path segments into a deterministic string. The same component in the same tree position always gets the same ID, regardless of whether the code runs on the server or the client.

function StablePasswordField() {
  const hintId = useId();
  
  return (
    <>
      <label>
        Password:
        <input type="password" aria-describedby={hintId} />
      </label>
      <p id={hintId}>Must contain at least 18 characters</p>
    </>
  );
}

On the server, useId returns ":r1:". On the client, hydration assigns ":r1:" to the same component instance. The id and aria-describedby attributes match. React skips the re-render and preserves the server HTML.

The ID format includes a colon prefix to ensure it does not conflict with developer-defined IDs. The alphanumeric suffix encodes the component's tree position in a compact form. If the same component renders twice in the tree, each instance gets a distinct ID because each occupies a different position.

flowchart TD
    A("React component tree")
    B("Root renders")
    C("StablePasswordField mounts at position 1")
    D("useId reads tree path")
    E("Generates :r1:")
    F("Server emits HTML with :r1:")
    G("Client hydrates at same position")
    H("useId reads tree path")
    I("Generates :r1:")
    J("IDs match, hydration succeeds")
    
    A --> B --> C --> D --> E --> F
    A --> G --> H --> I --> J
    
    style J stroke:#34d399,fill:#0b3b2e,color:#d1fae5
    style E stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px

This tree-based generation has a critical constraint: the component tree must be identical on the server and the client. If a component conditionally renders based on client-only state (window width, local storage, cookies), the tree structure diverges and useId produces different IDs. The useId documentation explicitly warns: "With server rendering, useId requires an identical component tree on the server and the client."

Developers working with multiple React applications on a single HTML page can set the identifierPrefix option in createRoot or hydrateRoot (as documented in the React 18 release notes) to namespace the IDs and prevent collisions between apps. The prefix prepends to every useId result within that root.

Building Accessible Form Components with useId

Accessible forms require explicit relationships between labels, inputs, error messages, and hint text. ARIA attributes like aria-describedby and aria-labelledby reference element IDs to build those relationships, documented in the MDN ARIA guide.

A production-ready form field needs a unique ID for the input, a unique ID for the hint text, and optionally a unique ID for error messages. The useId Hook provides a stable base that developers can extend with suffixes.

import { useId, useState } from 'react';
 
function EmailField() {
  const baseId = useId();
  const inputId = `${baseId}-input`;
  const hintId = `${baseId}-hint`;
  const errorId = `${baseId}-error`;
  
  const [email, setEmail] = useState('');
  const [error, setError] = useState('');
  
  const validateEmail = (value: string) => {
    if (!value.includes('@')) {
      setError('Email must contain an @ symbol');
    } else {
      setError('');
    }
  };
  
  return (
    <div>
      <label htmlFor={inputId}>
        Email Address
      </label>
      <input
        id={inputId}
        type="email"
        value={email}
        onChange={(e) => {
          setEmail(e.target.value);
          validateEmail(e.target.value);
        }}
        aria-describedby={error ? errorId : hintId}
        aria-invalid={!!error}
      />
      {error ? (
        <p id={errorId} role="alert">
          {error}
        </p>
      ) : (
        <p id={hintId}>
          We will never share your email
        </p>
      )}
    </div>
  );
}

This pattern creates a stable foundation for component libraries. A TextField component can call useId internally and expose the IDs through props if consumers need to reference them externally. The IDs never change across renders unless the component unmounts and remounts in a different tree position.

Screen readers announce the hint text when the input receives focus, then switch to the error message if validation fails. The aria-invalid attribute signals the error state to assistive technology. All relationships remain intact because the IDs are deterministic and match between server and client.

Developers building forms with libraries like React Hook Form or Formik can integrate useId into custom field components while letting the library handle validation and state management. The pattern described in useFormStatus and useFormState for accessible forms combines server actions with useId for full-stack form handling.

Accessible form field showing label, input, and hint text with aria relationships

Math.random() vs useId vs crypto.randomUUID(): The Complete Comparison

Developers choosing an ID generation strategy face three common options: Math.random(), crypto.randomUUID(), and useId. Each has different tradeoffs for collision probability, SSR compatibility, and browser support.

flowchart LR
    subgraph MathRandom["Math.random()"]
        MR1("Fast execution")
        MR2("Breaks SSR hydration")
        MR3("Low uniqueness guarantees")
    end
    
    subgraph CryptoUUID["crypto.randomUUID()"]
        CU1("Cryptographically unique")
        CU2("Breaks SSR hydration")
        CU3("Requires secure context")
    end
    
    subgraph UseId["useId()"]
        UI1("SSR hydration safe")
        UI2("Component-tree unique")
        UI3("Built for accessibility")
    end
    
    MR2 -.-> Fail("Hydration failure")
    CU2 -.-> Fail
    UI1 -.-> Success("Stable across renders")
    
    style Fail stroke:#ef4444,fill:#450a0a,color:#fca5a5
    style Success stroke:#34d399,fill:#0b3b2e,color:#d1fae5
ApproachSSR SafeCollision RiskBrowser SupportUse Case
Math.random()NoMedium (birthday paradox applies)UniversalClient-only apps with no SSR
crypto.randomUUID()NoNegligible (128-bit UUID)Modern browsers only (requires secure context)Secure client-only tokens
useIdYesNone within a React treeReact 18+Accessibility IDs in SSR apps

Math.random() generates a floating-point number between 0 and 1, which developers typically convert to a string with .toString(36).slice(2). This produces short alphanumeric strings but does not guarantee uniqueness. The birthday paradox means collisions become likely after a few thousand IDs.

crypto.randomUUID() (introduced in WHATWG Secure Contexts) generates RFC 4122 version 4 UUIDs with 128 bits of entropy. Collisions are astronomically unlikely. However, the function requires a secure context (HTTPS or localhost) and is unavailable in older browsers and some Node.js environments. More importantly, it runs independently on the server and client, producing different UUIDs and breaking hydration exactly like Math.random().

useId does not aim for cryptographic security or global uniqueness. It guarantees uniqueness within a single React component tree and stability across server and client renders. The IDs are short (typically 3-5 characters) and readable in DevTools. For accessibility attributes, this is the correct tradeoff: developers need deterministic, collision-free IDs that survive hydration, not UUIDs that resist brute-force attacks.

Developers sometimes combine approaches: generate a UUID on the server, store it in component state, and reference it in accessibility attributes. This works but adds unnecessary complexity. The UUID never leaves the component, so cryptographic security provides no benefit. The state update forces an extra render. useId solves the same problem with zero client-side state and zero re-renders.

Common useId Pitfalls: When NOT to Use It

The official useId documentation lists two explicit anti-patterns: "should not be used to generate keys in a list" and "should not be used to generate cache keys."

React list keys must be stable across re-renders and tied to the underlying data. A key like user-${user.id} lets React track which user moved when the list re-orders. A key from useId changes when the component remounts, causing React to destroy and recreate DOM nodes unnecessarily.

// WRONG: useId for list keys
function UserList({ users }: { users: User[] }) {
  return (
    <ul>
      {users.map((user) => {
        const id = useId(); // Creates a new ID on every render
        return <li key={id}>{user.name}</li>;
      })}
    </ul>
  );
}
 
// CORRECT: Use data as the key source
function UserList({ users }: { users: User[] }) {
  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}
flowchart LR
    A("List renders with 3 items")
    B("useId generates :r1:, :r2:, :r3:")
    C("User reorders list")
    D("useId generates :r1:, :r2:, :r3: again")
    E("Keys match wrong items")
    F("React destroys and recreates DOM")
    
    A --> B --> C --> D --> E --> F
    
    style E stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
    style F stroke:#ef4444,fill:#450a0a,color:#fca5a5

Cache keys have a similar requirement: they must derive from the data being cached, not from the component requesting it. If two components request the same data, they should use the same cache key. useId generates different IDs for different component instances, so each instance would create a separate cache entry for identical data.

The React 18 release notes also mention that useId "currently cannot be used in async Server Components." Server Components (as described in the experimental React Server Components RFC) suspend during render to fetch data. React cannot determine the component's tree position until the suspension resolves, so useId cannot generate a stable path-based ID.

Developers building Jotai atomic state or similar libraries should not use useId to identify atoms. Atom identity must persist across component unmounts and remounts. useId ties to the component lifecycle, so an atom identified by useId would lose its state when the component unmounts.

Production Patterns: Multiple IDs in a Single Component

Complex form controls often need multiple related IDs. A combobox requires an ID for the input, an ID for the listbox, and IDs for each option. A date picker needs IDs for the input, the calendar popup, and potentially each date cell.

The official useId documentation shows the recommended pattern: "If you need to give IDs to multiple related elements, you can call useId to generate a shared prefix for them."

import { useId } from 'react';
 
function Combobox({ options }: { options: string[] }) {
  const baseId = useId();
  const inputId = `${baseId}-input`;
  const listboxId = `${baseId}-listbox`;
  
  return (
    <div>
      <label htmlFor={inputId}>Choose an option:</label>
      <input
        id={inputId}
        role="combobox"
        aria-expanded="false"
        aria-controls={listboxId}
        aria-autocomplete="list"
      />
      <ul id={listboxId} role="listbox">
        {options.map((option, index) => (
          <li key={option} id={`${baseId}-option-${index}`} role="option">
            {option}
          </li>
        ))}
      </ul>
    </div>
  );
}

This pattern creates a stable prefix that all related IDs share. The input gets ":r1:-input", the listbox gets ":r1:-listbox", and each option gets ":r1:-option-0", ":r1:-option-1", etc. The prefix never changes across renders, so ARIA relationships remain intact.

Developers sometimes call useId multiple times in the same component instead of concatenating suffixes. This works but generates longer IDs and slightly increases the risk of tree position collisions if the component structure changes. A single call with suffixes is more explicit about the relationship between the IDs.

For components that render in a loop, the key should come from the data (as discussed earlier), but the internal IDs can still use useId. Each instance of the component gets a unique useId result based on its position in the tree.

function FormFieldList({ fields }: { fields: FieldConfig[] }) {
  return (
    <>
      {fields.map((field) => (
        <FormField key={field.name} config={field} />
      ))}
    </>
  );
}
 
function FormField({ config }: { config: FieldConfig }) {
  const baseId = useId();
  const inputId = `${baseId}-input`;
  const hintId = `${baseId}-hint`;
  
  return (
    <div>
      <label htmlFor={inputId}>{config.label}</label>
      <input id={inputId} aria-describedby={hintId} />
      <p id={hintId}>{config.hint}</p>
    </div>
  );
}

This separation keeps keys tied to data (the field.name) while letting useId handle the internal ID generation for each field instance. The pattern described in building apps with React Hooks extends this to entire forms managed through custom Hooks.

Frequently Asked Questions

Can I use useId to generate database record IDs?

No. useId generates IDs tied to the component's position in the React tree. These IDs change when the component unmounts and remounts, and they are not globally unique across different React trees or server instances. Database IDs require globally unique identifiers like UUIDs or auto-incrementing integers generated by the database itself.

Why does useId return a colon-prefixed string instead of a plain number?

The colon prefix (:r1:, :r2:) ensures the generated IDs cannot collide with developer-defined IDs. If useId returned "1" or "field-1", developers who manually assign id="1" elsewhere in the tree would create conflicts. The colon makes it visually distinct and prevents accidental collisions.

Does useId work with React.lazy and code splitting?

Yes. useId works correctly with dynamically imported components because React's tree structure includes lazily loaded components in the parent path. The ID remains stable as long as the component mounts in the same tree position after the code chunk loads.

Can I call useId conditionally or inside a loop?

No. useId is a Hook, so it must follow the Rules of Hooks. Call it only at the top level of your component or custom Hook, never inside conditionals, loops, or nested functions. If you need multiple IDs, call useId once and concatenate suffixes, or extract a separate component that calls useId internally.

What happens if the server and client trees do not match?

React logs a hydration mismatch warning and re-renders the component on the client. The useId result may differ between the server HTML and the re-rendered client DOM, breaking accessibility relationships temporarily until the client render completes. The useId documentation explicitly requires an identical component tree on the server and client for the Hook to work correctly.

Making Your React Apps Accessible and Hydration-Safe

The useId Hook eliminates an entire class of SSR bugs by making ID generation deterministic. Developers no longer need to choose between accessibility and server rendering, or build workarounds with useEffect and client-only state.

Production applications using Next.js, Remix, or custom SSR setups should audit all form components and accessibility relationships. Any hardcoded ID or Math.random() call in a server-rendered component is a hydration risk. Replacing them with useId costs nothing at runtime and prevents subtle bugs that only appear in production.

For teams building component libraries, useId should be the default for any internal ID generation. Expose the base ID through props if consumers need to reference it externally, but handle the generation internally. This pattern works across frameworks and server rendering strategies without requiring consumers to understand hydration mechanics.

The Hook is not a silver bullet. It does not replace data-derived keys, cache identifiers, or globally unique tokens. It solves one specific problem: generating stable, collision-free IDs for DOM elements within a React component tree. For that use case, it is the only correct solution in a server-rendered application.