React useId: Stable IDs for Accessibility Attributes
AI generated
</>
{ }
React 18 · Accessibility · SSR · Hooks
React useId
Stable IDs for Accessibility Attributes

Accessibility in React components often fails on one detail: IDs for aria-labelledby and htmlFor must be unique and must match between server and client. useId solves both at once, without boilerplate and without hydration errors.

9 min read useId · aria-labelledby · htmlFor · aria-describedby · SSR · Next.js React 18+ · TypeScript

1. The ID Problem in React Components

In HTML, IDs connect elements to each other: a <label> element references an <input> element via htmlFor and id. ARIA attributes such as aria-labelledby, aria-describedby and aria-controls reference other elements via their IDs. These connections are fundamental for accessibility, screen readers use them to navigate through forms and understand which label belongs to which input field.

In React, this creates a problem: if the same component is rendered multiple times, all instances would have the same static ID. A label with htmlFor="email" and an input with id="email", with two instances of the component that produces four elements with duplicate IDs. Browser behavior with duplicate IDs is undefined: some browsers pick the first element, some pick the last, some ignore the connection entirely. Screen reader behavior is even less predictable.

The classic "solution" was a global counter (let idCounter = 0; const nextId = () => \`id-\${idCounter++}\`) or passing IDs down as props. Both approaches have problems: a global counter is not SSR-safe, on the server a new render happens on every request, but the counter keeps accumulating. That leads to hydration mismatches because server IDs and client IDs no longer match. Passing props is boilerplate and pushes the responsibility onto the caller. useId solves both problems natively.

2. Why ARIA IDs Must Be Unique

The ARIA specification defines that IDs referenced in ARIA attributes must be unique across the entire document. This is not a recommendation, it is a technical requirement: the accessibility tree used by screen readers and other assistive technologies is built by the browser from the DOM. If an ID occurs more than once in the document, it is unclear which element the reference actually points to.

All attributes that use ID references are affected: aria-labelledby (the label of an element), aria-describedby (description or hint), aria-controls (controlled elements, e.g. an accordion panel), aria-owns (ownership of elements that sit elsewhere in the DOM structure) and htmlFor (label-to-input connection). All of these references assume unique IDs. In a React application built from reusable components, that is hard to get right without a systematic solution.

A concrete scenario: a design system component FormField encapsulates a label, an input and an error message. The error message should be connected to the input via aria-describedby. If FormField is used five times in one form, every instance needs its own, unique ID for the input and the error message. useId generates these IDs automatically and correctly.

3. useId: Syntax and Basic Behavior

useId takes no parameters and returns a string. That string is unique for every component instance, stable across re-renders, and matches between server-side and client-side rendering. The format of the generated ID is implementation-dependent (e.g. :r0:, :r1:, and so on) and should never be parsed or predicted. Important: the generated IDs are meant for HTML attributes, not as keys in lists (the key prop) and not as database keys.

Internally, the hook uses the component's positional index in the render tree, combined with the React root index (when there are multiple React roots on one page). That produces deterministic IDs that match between server and client, the basic prerequisite for hydration without a mismatch. With Concurrent Mode and streaming SSR, useId works correctly because the ID generation is tied to the component's position in the tree, not to the timing of the render.


import { useId } from 'react';

// Basic usage, one useId call per component instance
function EmailField() {
  // Unique, stable ID for this specific instance
  const id = useId();

  return (
    <div>
      {/* htmlFor connects label to input via shared id */}
      <label htmlFor={id}>Email address</label>
      <input
        id={id}
        type="email"
        name="email"
        autoComplete="email"
        aria-required="true"
      />
    </div>
  );
}

// Multiple instances get different IDs automatically
export function NewsletterForm() {
  return (
    <form>
      {/* Each EmailField instance gets its own unique ID */}
      <EmailField />
      {/* A second instance would get a different ID, no collision */}
    </form>
  );
}

// Prefix pattern, derive multiple related IDs from one useId call
function SearchField() {
  const baseId = useId();
  const inputId = `${baseId}-input`;
  const hintId = `${baseId}-hint`;
  const errorId = `${baseId}-error`;

  return (
    <div>
      <label htmlFor={inputId}>Search</label>
      <input
        id={inputId}
        type="search"
        aria-describedby={`${hintId} ${errorId}`}
      />
      <p id={hintId} className="text-sm text-slate-500">
        Enter at least 3 characters
      </p>
      <p id={errorId} className="text-sm text-red-600" role="alert">
        {/* Error message rendered here when validation fails */}
      </p>
    </div>
  );
}

4. useId in Form Components

Form components are the most common use case for useId. A complete form field component typically consists of a label, an input, an optional hint text and an error message. For a correct accessibility implementation, the label and input must be connected via htmlFor/id, and the input must reference the hint and the error message via aria-describedby. That means at least two different IDs per component instance.

The recommended pattern: a single useId call per component, and then all required IDs are derived by appending suffixes. const id = useId(); const inputId = \`\${id}-input\`; const errorId = \`\${id}-error\`. This pattern works reliably because the base ID is unique and all derived IDs are therefore unique as well. It is also maintenance-friendly: whenever a new element that needs an ID is added, another suffix is simply derived from the base.

An important nuance about aria-describedby: the attribute accepts a space-separated list of IDs. That makes it possible to reference the hint text and the error message at the same time: aria-describedby={\`\${hintId} \${errorId}\`}. Screen readers read both texts aloud, the hint first, then the error. That is an important pattern for accessible forms, and it does not work without correct, unique IDs.

5. Multiple IDs from a Single useId Call

The suffix pattern, where every ID of a component is derived from a single base ID, is the recommended approach. It has a practical advantage over calling useId multiple times: all IDs of one component share the same base prefix, which makes it immediately visible in the DOM which elements belong together while debugging. If you see :r3:-input, :r3:-hint and :r3:-error in the DOM, it is clear that they come from the same component instance.

Calling useId multiple times in the same component is technically possible and correct, every call generates a new, unique ID. But those IDs are produced without any obvious relationship to each other. For debugging and code readability, the suffix pattern is the better choice. The only case where multiple useId calls make sense is when a component conceptually contains independent parts that have no relationship to each other and each need their own ID namespace.

When using useId in custom hook libraries, the hook should be moved into the custom hook itself, not into the component that calls the custom hook. That fully encapsulates the ID generation inside the hook and keeps the component that calls the hook simpler. A custom hook useFormField could call useId internally and return the finished, formatted IDs and props to the outside.


import { useId } from 'react';

interface FormFieldProps {
  label: string;
  hint?: string;
  error?: string;
  type?: string;
  name: string;
  required?: boolean;
}

// Encapsulates all ID logic, caller never manages IDs
function FormField({
  label,
  hint,
  error,
  type = 'text',
  name,
  required = false,
}: FormFieldProps) {
  // Single useId call, all related IDs derived from one base
  const baseId = useId();
  const inputId = `${baseId}-input`;
  const hintId = `${baseId}-hint`;
  const errorId = `${baseId}-error`;

  // Build aria-describedby from available descriptors
  const describedBy = [hint && hintId, error && errorId]
    .filter(Boolean)
    .join(' ');

  return (
    <div className="space-y-1">
      <label htmlFor={inputId} className="block text-sm font-medium text-slate-700">
        {label}
        {required && <span aria-hidden="true" className="text-red-500 ml-1">*</span>}
      </label>
      <input
        id={inputId}
        type={type}
        name={name}
        required={required}
        aria-required={required}
        aria-describedby={describedBy || undefined}
        aria-invalid={error ? 'true' : undefined}
        className={`w-full rounded-lg border px-3 py-2 ${
          error ? 'border-red-400 bg-red-50' : 'border-slate-300'
        }`}
      />
      {hint && (
        <p id={hintId} className="text-xs text-slate-500">
          {hint}
        </p>
      )}
      {error && (
        <p id={errorId} className="text-xs text-red-600" role="alert">
          {error}
        </p>
      )}
    </div>
  );
}

// Usage, no ID management needed by the caller
export function RegistrationForm() {
  return (
    <form className="space-y-4">
      {/* Each instance gets unique IDs automatically */}
      <FormField name="email" label="Email" type="email" required hint="We never send marketing emails" />
      <FormField name="username" label="Username" required error="Username already taken" />
      <FormField name="password" label="Password" type="password" required hint="At least 12 characters" />
    </form>
  );
}

6. SSR and Hydration: Why useId Is Safe

The fundamental problem with IDs in SSR applications is the hydration mismatch. If the server renders HTML with certain IDs and the client generates different IDs during hydration, a mismatch occurs. React then warns in the console and tries to repair the DOM, which can lead to visual glitches. A global ID counter is SSR-unsafe because the counter value can differ between server and client (on the client, other components may already have been initialized, for example).

useId solves this problem through deterministic generation based on the component's position in the render tree. Because the render tree position is identical on server and client (same component code, same props), the generated IDs are identical as well. React can compare the server HTML snapshot with the client render and finds matching IDs, no mismatch, no DOM repair, no visual glitch.

In React applications with multiple React roots (for example when React is embedded into an existing page), the root index is factored into the ID generation. That ensures two React roots on the same page never have ID collisions. The identifierPrefix attribute of the createRoot function also makes it possible to set a manual prefix, useful when multiple React apps run on one page and IDs must not collide.

7. useId vs. Other ID Approaches Compared

Before useId existed, developers used various workarounds. Each one has specific weaknesses that useId elegantly solves.

Approach SSR-safe Concurrent Mode No boilerplate
Global counter No, mismatch No Yes
useState + useEffect No, mismatch No No, lots of code
ID as prop Yes Yes No, prop drilling
Math.random() No, mismatch No Yes
useId Yes, deterministic Yes Yes, one hook call

The useState+useEffect approach was a widespread workaround: on the server, useState('') returns an empty ID, and useEffect sets the ID on the client. That prevents the mismatch, but it means labels and inputs are unconnected on the server. Screen readers see the initial server HTML without ID connections, which is also problematic for SEO with crawlers that do not execute JavaScript. useId does not have this problem: the IDs are already set correctly in the server HTML.

8. Common Mistakes and Limitations

The most common mistake when using useId: using the generated ID as a key for list elements. The React documentation is explicit here: useId is not meant for key props. Keys in lists should come from the data itself (for example database keys, UUIDs from an API). A generated ID would be new on every render and would break React's reconciliation behavior.

A second common mistake: calling useId outside components or custom hooks. Like every React hook, useId may only be called inside components or custom hooks, not in event handlers, not inside useMemo or useCallback callbacks, not outside of rendering. This restriction applies to all hooks and is checked by React lint rules.

A third mistake concerns the assumption that the format of the generated ID is stable. The format can change between React versions. Anyone who relies on the format in tests, CSS selectors or JavaScript queries is writing fragile code. The ID should only be used as a value for HTML attributes, never as a query selector or for any other purpose. For testing, it is recommended to find elements by accessible properties (such as role, label text) rather than by ID.


import { useId } from 'react';

// WRONG: useId used as list key, IDs are not stable across renders for list reconciliation
function WrongList({ items }: { items: string[] }) {
  const id = useId();
  return (
    <ul>
      {items.map((item, index) => (
        // WRONG: Don't use useId for list keys, use item data or index
        <li key={`${id}-${index}`}>{item}</li>
      ))}
    </ul>
  );
}

// RIGHT: List keys from data, useId only for ARIA attributes
function RightList({ items }: { items: { id: string; label: string }[] }) {
  const listId = useId();

  return (
    <ul id={listId} role="listbox" aria-label="Selection options">
      {items.map((item) => (
        // Data-driven key, not related to useId
        <li key={item.id} role="option" aria-selected={false}>
          {item.label}
        </li>
      ))}
    </ul>
  );
}

// RIGHT: Custom hook encapsulates ID logic
function useFormField(name: string) {
  const baseId = useId();
  return {
    inputProps: {
      id: `${baseId}-input`,
      name,
      'aria-describedby': `${baseId}-hint ${baseId}-error`,
    },
    labelProps: { htmlFor: `${baseId}-input` },
    hintProps: { id: `${baseId}-hint` },
    errorProps: { id: `${baseId}-error`, role: 'alert' as const },
  };
}

// Clean component, no manual ID management
function PasswordField() {
  const field = useFormField('password');
  return (
    <div>
      <label {...field.labelProps}>Password</label>
      <input type="password" {...field.inputProps} />
      <p {...field.hintProps}>At least 12 characters</p>
      <p {...field.errorProps}>{/* error message */}</p>
    </div>
  );
}

9. TypeScript and Component Design

useId always returns a string and has no type parameters. The TypeScript integration is trivial, but there are important design decisions when using it inside component libraries. A well-designed component that uses useId internally should not need to expose an id prop to the outside, the ID generation is an implementation detail of the component. If an external ID prop is still needed (for example for e2e tests or CSS), it should be optional and able to override the internally generated ID.

The custom hook pattern is especially valuable for design system libraries. A useFormField hook that calls useId internally and returns finished props objects to the outside makes form components as simple as possible to use. The caller distributes the props objects onto the corresponding elements via the spread operator, without ever seeing or managing an ID. That is the cleanest API design for accessible form components.

When testing with React Testing Library, it is important not to find elements by generated IDs. getByRole('textbox', { name: 'Email address' }) is more robust and more accessible than getById, because it tests the correct label connection. A test that successfully finds an element via getByRole with a label text confirms that the htmlFor/id connection is correct, exactly what useId makes possible.

10. Summary

useId is a small hook with a large impact on accessibility. It generates unique, stable IDs for every component instance, deterministic on server and client, without global counters, without props, without useEffect. That turns correct ARIA connections in reusable components into a trivial task instead of a boilerplate problem.

The recommended pattern: one useId call per component (or custom hook), all required IDs derived through suffixes. Do not use it for list keys. Do not rely on the format of the ID. Ideally encapsulate the hook in a custom hook that returns finished props objects. And in tests, find elements by role and label text rather than by ID. Anyone who follows these four points implements accessible React components that work correctly in SSR environments and never have ID collisions when used multiple times.

useId: The Essentials at a Glance

What for

Unique IDs for aria-labelledby, aria-describedby, aria-controls and htmlFor, correct ARIA without ID collisions in components used multiple times.

SSR-safe

Deterministic based on the position in the render tree, server and client generate identical IDs, no hydration mismatch.

Suffix pattern

One useId call per component, derive every ID as a suffix: ${baseId}-input, ${baseId}-hint, ${baseId}-error.

Not for list keys

useId is not meant for key props in lists. Keys should come from data. useId is meant exclusively for HTML attributes.

11. FAQ: React useId

1What does useId do?
Generates a unique, stable ID per component instance, SSR-safe, stable across re-renders, ideal for aria-labelledby, aria-describedby and htmlFor.
2Suitable for list keys?
No. Derive list keys from data. useId is meant exclusively for HTML ID attributes.
3Why is it SSR-safe?
Based on the position in the render tree, identical on server and client. A global counter or Math.random() lead to hydration mismatches.
4Derive multiple IDs?
Suffix pattern: const id = useId(); then ${id}-input, ${id}-hint, ${id}-error. One useId call is enough for all IDs of a component.
5Is the ID format predictable?
No. The format can change between React versions. Use it only as a value for HTML attributes, never as a selector or for logic.
6Testing with useId?
Find elements by role and label text: getByRole('textbox', { name: 'Email' }). That tests the correct connection, not the ID itself.
7Custom hook or component?
A custom hook is better for design system libraries: encapsulates useId, returns finished props objects. The component distributes the props via spread without any ID knowledge.
8Multiple React roots?
React factors in the root index. Set identifierPrefix on createRoot for explicit control when multiple React apps run on one page.
9From which React version?
Stable since React 18.0.
10Difference to Math.random()?
Math.random() is not deterministic, different values on server and client, hydration mismatch. useId is deterministic and matches.