| |

React 52 ⚛️ Generating Unique IDs with useId

⏱️ Estimated read: 26 min | |

An accessible form is one where every input is correctly associated with its label, and every element that describes another element is linked to it programmatically. In plain HTML this is straightforward: you write an id on the input and an htmlFor on the label that matches it. In React, the same pattern breaks down the moment a component is reused. A PasswordField rendered twice on the same page produces two inputs with the same hardcoded id, which is invalid HTML and silently breaks screen reader associations. The traditional workarounds, such as random strings or incrementing counters, introduce their own problems: random IDs change on every render and mismatch between server and client, while counters depend on render order and break under concurrent rendering. The useId hook exists to solve exactly this problem, generating a unique, stable identifier for each component instance that works correctly with server rendering and hydration.

Key point: useId is not a general-purpose ID generator. It is a hook for creating unique IDs that tie DOM elements together for accessibility, and it is designed specifically to survive server rendering, hydration, and multiple component instances on the same page.


Why useId exists

The duplicate ID problem. A component that renders an input and its label with a hardcoded id works perfectly until it appears twice. When a second instance renders, the page contains two elements with the same id, which violates the HTML specification and confuses assistive technologies. Screen readers may associate a label with the wrong input, or ignore the association entirely. The more reusable a component is, the more likely this problem becomes .

The stability problem. The naive fix is to generate a random string at render time. But a random ID changes on every render, which breaks the association between the label and the input across updates. More critically, in server rendering, the random ID generated on the server will not match the random ID generated on the client during hydration. React will detect a mismatch, and the hydration will either fail or produce warnings that are difficult to debug .

The counter problem. A global incrementing counter, such as nextId++, produces unique values, but the values depend on the order in which components render. During server rendering, the order in which components are processed may not match the order in which the client hydrates them. If the orders differ, the server-generated IDs and client-generated IDs diverge, and hydration fails. React’s own documentation notes that this is “very difficult to guarantee with an incrementing counter” .

The tree-path solution. React solves both problems by generating IDs from the component’s position in the tree, not from a counter or a random source. The “parent path” of a component is identical on the server and the client if the trees match, regardless of the order in which rendering happens. This means the IDs generated by useId are stable across server and client, and unique across every instance of a component on the page .

The accessibility purpose. The primary use case for useId is accessibility attributes: htmlFor on a label, id on an input, aria-describedby on an input pointing to a hint paragraph, aria-labelledby on a group, and similar relationships. These are not cache keys or list keys or database identifiers. They are DOM-level pointers between elements that must survive rendering, hydration, and duplication .

The scope problem. Many developers encounter the same need outside of React: generating unique IDs for database rows, cache entries, or analytics events. useId is explicitly not for those purposes. The React documentation warns that the ID is stable while a component is mounted but may change during rendering, so it is unsuitable as a cache key. For those needs, use a dedicated ID generation library .


a. How useId works

useId is a hook that takes no parameters and returns a string. You call it at the top level of a component, store the result in a variable, and pass that value to the DOM attributes that need it .

The basic pattern

The canonical use is associating a label with an input. The same generated ID is passed to the label’s htmlFor attribute and the input’s id attribute, creating a programmatic link between them .

import { useId } from 'react';

function PasswordField() {
  const passwordHintId = useId();

  return (
    <>
      <label htmlFor={passwordHintId}>
        Password:
      </label>
      <input
        type="password"
        id={passwordHintId}
        aria-describedby={passwordHintId + '-hint'}
      />
      <p id={passwordHintId + '-hint'}>
        The password should contain at least 18 characters.
      </p>
    </>
  );
}

The useId call returns a string that is unique to this component instance. If PasswordField is rendered five times, each instance gets its own distinct ID, and no two labels point to the same input .

The generated format

The string returned by useId includes a colon character by default. React uses this format to ensure uniqueness across the tree. The colon is a valid character in an HTML id attribute, but it is not a valid character in a CSS selector. If you need to select the element by ID in CSS, you must either escape the colon or wrap the ID with a prefix .

const id = useId();
// id might be something like ":r0:"

// Valid in HTML:
// <input id=":r0:" />

// Invalid in CSS without escaping:
// #:r0: { ... }

// Solution: add a prefix for CSS usage.
const safeId = `field-${id}`;
// "field-:r0:"

The stability guarantee

The ID generated by useId is stable for the lifetime of the component instance. It does not change between renders, and it does not depend on the order in which components are rendered. This stability is what makes it safe to use in DOM attributes that must persist across updates .

function Field({ label }) {
  const id = useId();

  // Even if the parent re-renders with different data,
  // the id stays the same for this instance.
  return (
    <div>
      <label htmlFor={id}>{label}</label>
      <input id={id} type="text" />
    </div>
  );
}

The tree-path generation

Internally, React generates the ID from the component’s position in the element tree. The “parent path” is a sequence of indices that describes where the component sits relative to its ancestors. Because the tree structure is identical on the server and the client, the path is identical, and the generated ID is identical, even if the rendering order differs .

// Conceptual tree path for an ID:
// App
//  └─ Form
//      ├─ PasswordField (first child of Form)
//      └─ ConfirmField  (second child of Form)

// The ID for PasswordField is derived from the path App > Form > child[0].
// The ID for ConfirmField is derived from App > Form > child[1].
// Both are unique, and both are reproducible on server and client.

The multiple-call pattern

You can call useId more than once in the same component to generate multiple independent IDs. This is useful when a single component renders several distinct fields, each needing its own label association .

function RegistrationForm() {
  const nameId = useId();
  const emailId = useId();

  return (
    <form>
      <label htmlFor={nameId}>Name:</label>
      <input id={nameId} type="text" />

      <label htmlFor={emailId}>Email:</label>
      <input id={emailId} type="email" />
    </form>
  );
}

However, when the IDs are related and share a common context, it is often cleaner to call useId once and append suffixes. This reduces the number of hook calls and makes the relationship between the generated IDs explicit .

function Form() {
  const id = useId();

  return (
    <form>
      <label htmlFor={id + '-firstName'}>First Name:</label>
      <input id={id + '-firstName'} type="text" />

      <label htmlFor={id + '-lastName'}>Last Name:</label>
      <input id={id + '-lastName'} type="text" />
    </form>
  );
}

The multiple-apps problem

If you render two independent React applications on the same page, each with its own createRoot call, the IDs generated by useId in one app could theoretically collide with IDs from the other. React provides the identifierPrefix option to createRoot and hydrateRoot to prevent this. Each app gets its own prefix, and every ID generated by useId in that app starts with the prefix .

import { createRoot } from 'react-dom/client';

const root1 = createRoot(document.getElementById('root1'), {
  identifierPrefix: 'app1-'
});
root1.render(<App />);

const root2 = createRoot(document.getElementById('root2'), {
  identifierPrefix: 'app2-'
});
root2.render(<App />);

The same prefix must be passed to the server renderer if the app is server-rendered, so that the server-generated IDs and client-generated IDs match during hydration .


b. useId in server rendering and hydration

The primary motivation for useId‘s design is server rendering. In an SSR setup, React generates HTML on the server, sends it to the browser, and then “hydrates” it on the client by attaching event handlers and state. For hydration to work, the client’s output must match the server’s output exactly. If the IDs generated on the server differ from those generated on the client, React detects a mismatch and the hydration fails.

The identical-tree requirement

useId requires that the component tree on the server and the client be identical. If the trees differ, the parent paths differ, and the generated IDs differ. This means that any conditional rendering that depends on browser-only state must be handled carefully. The server and client must render the same structure on the first pass .

// Safe: server and client render the same tree initially.
function App() {
  const id = useId();
  return <input id={id} />;
}

// Risky: if isClient differs between server and client,
// the tree changes and IDs diverge.
function App() {
  const id = useId();
  const isClient = typeof window !== 'undefined';
  return isClient ? <input id={id} /> : <div />;
  // The server renders <div />, the client renders <input />.
  // The trees do not match. Hydration fails.
}

The hydration mismatch

If the IDs do not match during hydration, React logs a warning and may discard the server-rendered HTML in favor of a client-rendered version. For accessibility attributes, this means the label-input associations may break or require a full re-render. The warning is often cryptic, mentioning a mismatch in id or htmlFor attributes, and the root cause is usually a tree difference that is not obvious .

// Server output:
// <input id=":r0:" />
// <label htmlFor=":r0:">Name:</label>

// Client hydration output (if tree differs):
// <input id=":r1:" />
// <label htmlFor=":r1:">Name:</label>

// React warns: "Prop `id` did not match. Server: ':r0:' Client: ':r1:'"

The streaming consideration

In React 18 and later, server rendering can be streamed. Components may be rendered and sent to the browser in chunks as they become ready. The useId generation is designed to work with streaming, because the ID depends on the tree structure, not on the order in which chunks are flushed. As long as the tree structure is preserved, the IDs match .

The async server component limitation

useId cannot currently be used inside async Server Components. Async components introduce a different rendering model where the component’s position in the tree may not be known until the promise resolves. For now, ID generation in server components is limited to synchronous components .

// Not allowed in async Server Components:
async function ServerField() {
  const id = useId();  // Error: useId cannot be used in async components.
  return <input id={id} />;
}

The effects timing

Effects that depend on a useId value run after the component has mounted and the ID has been assigned. During the initial render, the ID is generated and passed to the DOM. Effects that read the ID from a ref or from the DOM will see the correct value. There is no race condition between ID generation and effect execution.


c. Common patterns and caveats

useId is a narrow tool with a clear purpose. Misusing it for other kinds of ID generation leads to bugs that are difficult to diagnose. This section covers the patterns that work and the pitfalls to avoid.

Form field pattern

The most common use of useId is in reusable form field components. A TextField component receives a label prop, generates an ID, and uses that ID to connect the label to the input. Every instance of the component gets its own ID, so the component can be rendered any number of times .

function TextField({ label, type = 'text' }) {
  const id = useId();

  return (
    <div>
      <label htmlFor={id}>{label}</label>
      <input id={id} type={type} />
    </div>
  );
}

function RegistrationForm() {
  return (
    <form>
      <TextField label="Username" />
      <TextField label="Email" type="email" />
      <TextField label="Password" type="password" />
    </form>
  );
}

The ARIA relationship pattern

For more complex accessibility relationships, a single generated ID can serve as the basis for multiple related IDs. The input’s aria-describedby points to a hint element, and if there is an error, the aria-describedby also points to the error message. This is built from one useId call with suffixes .

function FormField({ label, helpText, error }) {
  const id = useId();
  const helpId = id + '-help';
  const errorId = id + '-error';

  return (
    <div>
      <label htmlFor={id}>{label}</label>
      <input
        id={id}
        aria-describedby={`${helpId} ${error ? errorId : ''}`}
        aria-invalid={!!error}
      />
      <p id={helpId}>{helpText}</p>
      {error && <p id={errorId} role="alert">{error}</p>}
    </div>
  );
}

The combobox pattern

A combobox combines an input with a listbox. The input needs an id for its label, and it needs aria-controls pointing to the listbox. A single useId call provides the base for both .

function ComboBox({ label, options }) {
  const id = useId();
  const inputId = id + '-input';
  const listId = id + '-list';
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <label htmlFor={inputId}>{label}</label>
      <input
        id={inputId}
        role="combobox"
        aria-expanded={isOpen}
        aria-controls={listId}
        aria-autocomplete="list"
        onFocus={() => setIsOpen(true)}
      />
      {isOpen && (
        <ul id={listId} role="listbox">
          {options.map(opt => (
            <li key={opt.id} role="option">{opt.label}</li>
          ))}
        </ul>
      )}
    </div>
  );
}

The list-key anti-pattern

useId is explicitly not for generating keys in a list. React’s list keys should come from the data, not from a hook that depends on component position. Using useId for keys causes problems when the list is reordered, filtered, or virtualized, because the keys no longer correspond to the data items .

// Wrong: key from useId.
function ItemList({ items }) {
  const id = useId();
  return (
    <ul>
      {items.map(item => (
        <li key={id}>{item.name}</li>
        // Every item gets the same key!
      ))}
    </ul>
  );
}

// Correct: key from data.
function ItemList({ items }) {
  return (
    <ul>
      {items.map(item => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

The cache-key anti-pattern

useId should not be used to generate cache keys for the use API or for any other caching mechanism. The React documentation notes that the ID is stable while a component is mounted but may change during rendering. Cache keys must be derived from the data they represent, not from component position .

// Wrong: cache key from useId.
function UserProfile({ userId }) {
  const id = useId();
  const user = use(fetchUser(userId, { cacheKey: id }));
  // The cache key changes if the component moves in the tree.
}

// Correct: cache key from data.
function UserProfile({ userId }) {
  const user = use(fetchUser(userId, { cacheKey: `user-${userId}` }));
}

The conditional-call caveat

Like all hooks, useId must be called unconditionally at the top level of a component. Calling it inside a condition or a loop violates the rules of hooks and produces unpredictable results. If you need an ID only in some branches of a component, extract the branch into a separate component .

// Wrong: conditional call.
function Field({ showHelp, label }) {
  if (showHelp) {
    const id = useId();  // Rules of Hooks violation.
    return <label htmlFor={id}>{label}</label>;
  }
  return <span>{label}</span>;
}

// Correct: extract the branch.
function LabelWithId({ children }) {
  const id = useId();
  return <label htmlFor={id}>{children}</label>;
}

function Field({ showHelp, label }) {
  if (showHelp) {
    return <LabelWithId>{label}</LabelWithId>;
  }
  return <span>{label}</span>;
}

The prefix pattern

If you need IDs that are valid CSS selectors, or if you want to embed a semantic prefix in the ID for debugging purposes, wrap useId in a custom hook that adds a prefix. This is a simple pattern that keeps the generated ID unique while making it more useful .

function usePrefixedId(prefix) {
  const id = useId();
  return `${prefix}-${id}`;
}

function Form({ prefix = 'form' }) {
  const inputId = usePrefixedId(prefix);
  // inputId might be "form-:r0:"
  return <input id={inputId} />;
}

Complete Example Session

// ============================================
// PART 1: THE PROBLEM — HARDCODED IDS
// ============================================

// A form with hardcoded IDs.
// Works once, breaks on reuse.

function LoginForm() {
  return (
    <form>
      <label htmlFor="email">Email:</label>
      <input id="email" type="email" />

      <label htmlFor="password">Password:</label>
      <input id="password" type="password" />
    </form>
  );
}

// Render two of these on the same page:
// <LoginForm />
// <LoginForm />
// Result: two inputs with id="email", two with id="password".
// Invalid HTML. Screen readers associate labels incorrectly.


// ============================================
// PART 2: THE NAIVE FIX — RANDOM IDS
// ============================================

// Generate a random ID per render.
function RandomIdField({ label }) {
  const id = Math.random().toString(36).slice(2);

  return (
    <div>
      <label htmlFor={id}>{label}</label>
      <input id={id} type="text" />
    </div>
  );
}

// Problems:
// 1. The ID changes on every render. React re-renders the input
//    with a new id, breaking any external references.
// 2. In SSR, the server's random ID never matches the client's.
//    Hydration fails with a warning.


// ============================================
// PART 3: THE COUNTER FIX — ALSO BROKEN
// ============================================

let nextId = 0;

function CounterIdField({ label }) {
  const [id] = useState(() => `field-${nextId++}`);

  return (
    <div>
      <label htmlFor={id}>{label}</label>
      <input id={id} type="text" />
    </div>
  );
}

// Problems:
// 1. The ID depends on render order.
// 2. In SSR, server render order may differ from client hydration order.
// 3. In concurrent rendering, React may render components speculatively,
//    advancing the counter for renders that are never committed.
// 4. The ID is not stable across tree changes.


// ============================================
// PART 4: THE CORRECT FIX — useId
// ============================================

import { useId } from 'react';

function IdField({ label }) {
  const id = useId();

  return (
    <div>
      <label htmlFor={id}>{label}</label>
      <input id={id} type="text" />
    </div>
  );
}

// Each instance gets a unique ID derived from its tree position.
// The ID is stable across renders.
// The ID matches between server and client.
// The component can be rendered any number of times.


// ============================================
// PART 5: MULTIPLE RELATED ELEMENTS
// ============================================

function PasswordField() {
  const id = useId();
  const hintId = id + '-hint';

  return (
    <div>
      <label htmlFor={id}>Password:</label>
      <input
        id={id}
        type="password"
        aria-describedby={hintId}
      />
      <p id={hintId}>
        Use at least 18 characters.
      </p>
    </div>
  );
}

// One useId call. Two related elements linked.
// The input's aria-describedby points to the hint paragraph.
// Screen readers announce the hint when the input is focused.


// ============================================
// PART 6: THE ARIA ERROR PATTERN
// ============================================

function FormField({ label, helpText, error }) {
  const id = useId();
  const helpId = id + '-help';
  const errorId = id + '-error';

  const describedBy = error
    ? `${helpId} ${errorId}`
    : helpId;

  return (
    <div>
      <label htmlFor={id}>{label}</label>
      <input
        id={id}
        aria-describedby={describedBy}
        aria-invalid={!!error}
      />
      <p id={helpId}>{helpText}</p>
      {error && (
        <p id={errorId} role="alert">{error}</p>
      )}
    </div>
  );
}

// When error is present, the input is described by both
// the help text and the error message.
// When no error, only the help text.
// The IDs are built from one base ID with suffixes.


// ============================================
// PART 7: LIST KEYS — DO NOT USE useId
// ============================================

// Wrong:
function BadList({ items }) {
  const id = useId();
  return (
    <ul>
      {items.map(item => (
        <li key={id}>{item.name}</li>
        // Every item gets the same key.
      ))}
    </ul>
  );
}

// Correct:
function GoodList({ items }) {
  return (
    <ul>
      {items.map(item => (
        <li key={item.id}>{item.name}</li>
        // Key comes from the data.
      ))}
    </ul>
  );
}


// ============================================
// PART 8: SERVER RENDERING
// ============================================

// Server: renderToString or renderToPipeableStream.
// Client: hydrateRoot with the same identifierPrefix.

// server.js
import { renderToPipeableStream } from 'react-dom/server';
import App from './App';

renderToPipeableStream(<App />, {
  identifierPrefix: 'myapp-'
});

// client.js
import { hydrateRoot } from 'react-dom/client';
import App from './App';

hydrateRoot(document.getElementById('root'), <App />, {
  identifierPrefix: 'myapp-'
});

// Both server and client use the same prefix.
// IDs generated by useId match across the boundary.
// Hydration succeeds without warnings.


// ============================================
// PART 9: MULTIPLE ROOTS ON ONE PAGE
// ============================================

// Two independent React apps on the same page.
// Each needs its own ID namespace.

import { createRoot } from 'react-dom/client';

const root1 = createRoot(document.getElementById('root1'), {
  identifierPrefix: 'app1-'
});
root1.render(<Widget />);

const root2 = createRoot(document.getElementById('root2'), {
  identifierPrefix: 'app2-'
});
root2.render(<Widget />);

// Widget's useId values are prefixed with 'app1-' or 'app2-'.
// No collision between the two apps.


// ============================================
// PART 10: WHAT useId IS FOR
// ============================================

// useId is for:
// - htmlFor on labels
// - id on inputs
// - aria-describedby, aria-labelledby, aria-controls
// - Any DOM attribute that links two elements together

// useId is NOT for:
// - React list keys (use data keys)
// - Cache keys (use data-derived keys)
// - Database identifiers (use a UUID library)
// - Analytics event IDs (use a dedicated ID generator)
// - Anything that needs to be stable beyond component mount

// The hook is narrow on purpose. Its guarantees are exactly
// what accessibility attributes need: unique per instance,
// stable across renders, matching across server and client.

The ten parts progressed from the problem of hardcoded IDs through the two common broken fixes, the correct solution, multi-element patterns, ARIA relationships, the list-key anti-pattern, server rendering, multiple roots, and a summary of scope. The same form-field scenario ran through every part, showing how useId solves the uniqueness problem that every reusable component faces.


Quick Reference

useId API

ParameterTypeDescription
——Takes no parameters
ReturnsstringUnique, stable ID string

Common Use Cases

AttributePurposeExample
htmlFor / idLabel-input association<label htmlFor={id}>
aria-describedbyInput to hint/error<input aria-describedby={hintId}>
aria-labelledbyGroup to heading<div aria-labelledby={titleId}>
aria-controlsCombobox to listbox<input aria-controls={listId}>

useId vs Other ID Approaches

ApproachStableSSR-SafeUnique per Instance
HardcodedYesYesNo
RandomNoNoYes
CounterYesNoYes
useIdYesYesYes

identifierPrefix

APIOptionPurpose
createRootidentifierPrefixPrefix for client-only apps
hydrateRootidentifierPrefixPrefix for hydration
renderToPipeableStreamidentifierPrefixPrefix for server rendering

Best Practices

✅ Do This:

// Use useId for label-input associations.
const id = useId();
<label htmlFor={id}>Name:</label>
<input id={id} />  // ✅

// Build related IDs from one base with suffixes.
const id = useId();
const hintId = id + '-hint';  // ✅

// Use the same identifierPrefix on server and client.
renderToPipeableStream(<App />, { identifierPrefix: 'app-' });  // ✅
hydrateRoot(root, <App />, { identifierPrefix: 'app-' });  // ✅

// Wrap useId in a custom hook for prefixed IDs.
function usePrefixedId(prefix) {
  return `${prefix}-${useId()}`;  // ✅
}

// Extract components when conditional IDs are needed.
function FieldWithId({ label }) {
  const id = useId();
  return <label htmlFor={id}>{label}</label>;
}  // ✅

❌ Don’t Do This:

// Don't use useId for list keys.
const id = useId();
items.map(item => <li key={id}>...</li>);  // ❌

// Don't use useId for cache keys.
const id = useId();
use(fetchData(url, { cacheKey: id }));  // ❌

// Don't use random IDs in SSR.
const id = Math.random().toString(36);  // ❌ Hydration mismatch

// Don't use a global counter in SSR.
let nextId = 0;
const id = `field-${nextId++}`;  // ❌ Order-dependent

// Don't call useId conditionally.
if (show) {
  const id = useId();  // ❌ Rules of Hooks violation
}

// Don't rely on useId outside React.
// It only works inside components.  // ❌

Common Pitfalls

PitfallWhy It HappensFix
Hydration mismatchServer and client trees differEnsure identical trees on first render
Duplicate IDsHardcoded id in reusable componentUse useId for every instance
ID changes on renderUsing Math.random or Date.nowUse useId for stability
useId used as list keyMisunderstanding the hook’s purposeUse data-derived keys
CSS selector failsColon in useId stringAdd a prefix for CSS
Rules of Hooks violationCalling useId in a conditionExtract a component
Multiple apps collideNo identifierPrefixPass distinct prefixes to each root
Async Server Component erroruseId not supported in async componentsUse sync component or other ID source

Real-World Examples

1. Basic Label-Input Association

function TextField({ label }) {
  const id = useId();
  return (
    <>
      <label htmlFor={id}>{label}</label>
      <input id={id} type="text" />
    </>
  );
}

2. Input with Hint Text

function PasswordField() {
  const id = useId();
  const hintId = id + '-hint';
  return (
    <>
      <label htmlFor={id}>Password:</label>
      <input id={id} type="password" aria-describedby={hintId} />
      <p id={hintId}>At least 18 characters.</p>
    </>
  );
}

3. Input with Error Message

function FormField({ label, error }) {
  const id = useId();
  const errorId = id + '-error';
  return (
    <>
      <label htmlFor={id}>{label}</label>
      <input
        id={id}
        aria-describedby={error ? errorId : undefined}
        aria-invalid={!!error}
      />
      {error && <p id={errorId} role="alert">{error}</p>}
    </>
  );
}

4. Combobox with Listbox

function ComboBox({ label, options }) {
  const id = useId();
  const inputId = id + '-input';
  const listId = id + '-list';
  return (
    <>
      <label htmlFor={inputId}>{label}</label>
      <input id={inputId} role="combobox" aria-controls={listId} />
      <ul id={listId} role="listbox">...</ul>
    </>
  );
}

5. Multiple Fields with One Base ID

function Form() {
  const id = useId();
  return (
    <form>
      <label htmlFor={id + '-first'}>First:</label>
      <input id={id + '-first'} />
      <label htmlFor={id + '-last'}>Last:</label>
      <input id={id + '-last'} />
    </form>
  );
}

6. Prefixed ID for CSS Selectors

function useCssId(prefix) {
  return `${prefix}-${useId()}`;
}

function Field({ label }) {
  const id = useCssId('field');
  return <input id={id} />;
}

7. Server and Client Prefix Match

// Server
renderToPipeableStream(<App />, { identifierPrefix: 'app-' });

// Client
hydrateRoot(root, <App />, { identifierPrefix: 'app-' });

8. Multiple Roots on One Page

const root1 = createRoot(el1, { identifierPrefix: 'a-' });
const root2 = createRoot(el2, { identifierPrefix: 'b-' });

9. Field Group with Heading

function FieldGroup({ legend, children }) {
  const id = useId();
  return (
    <fieldset aria-labelledby={id}>
      <legend id={id}>{legend}</legend>
      {children}
    </fieldset>
  );
}

10. Tooltip Association

function TooltipTrigger({ label, tooltip }) {
  const id = useId();
  return (
    <>
      <button aria-describedby={id}>{label}</button>
      <div id={id} role="tooltip">{tooltip}</div>
    </>
  );
}

Visual

useId Across Server and Client

┌────────────────────────────────────────────────────────────┐
│  useId IN SERVER RENDERING AND HYDRATION                   │
│                                                            │
│  SERVER                                                    │
│  ┌──────────────────────────────────────────────────┐     │
│  │  renderToPipeableStream(<App />, {                 │     │
│  │    identifierPrefix: 'app-'                        │     │
│  │  })                                                │     │
│  │  Output HTML:                                      │     │
│  │    <label for="app-:r0:">Name:</label>            │     │
│  │    <input id="app-:r0:" />                         │     │
│  └──────────────────────────────────────────────────┘     │
│                        │                                   │
│                        │  HTML sent to browser            │
│                        v                                   │
│  CLIENT                                                    │
│  ┌──────────────────────────────────────────────────┐     │
│  │  hydrateRoot(root, <App />, {                      │     │
│  │    identifierPrefix: 'app-'                        │     │
│  │  })                                                │     │
│  │  React renders the same tree.                      │     │
│  │  useId generates 'app-:r0:' again.                 │     │
│  │  IDs match. Hydration succeeds.                    │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  If the trees differ, IDs differ, hydration fails.         │
│                                                            │
└────────────────────────────────────────────────────────────┘

The Duplicate ID Problem

┌────────────────────────────────────────────────────────────┐
│  HARDCODED IDS vs useId                                    │
│                                                            │
│  HARDCODED                                                 │
│  ┌──────────────────────────────────────────────────┐     │
│  │  <PasswordField />                                │     │
│  │    <label for="pw">Password:</label>              │     │
│  │    <input id="pw" />                              │     │
│  │                                                   │     │
│  │  <PasswordField />                                │     │
│  │    <label for="pw">Password:</label>              │     │
│  │    <input id="pw" />                              │     │
│  │                                                   │     │
│  │  Two elements with id="pw". Invalid HTML.         │     │
│  │  Screen reader confusion.                         │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  WITH useId                                                │
│  ┌──────────────────────────────────────────────────┐     │
│  │  <PasswordField />                                │     │
│  │    <label for=":r0:">Password:</label>            │     │
│  │    <input id=":r0:" />                            │     │
│  │                                                   │     │
│  │  <PasswordField />                                │     │
│  │    <label for=":r1:">Password:</label>            │     │
│  │    <input id=":r1:" />                            │     │
│  │                                                   │     │
│  │  Unique IDs per instance. Valid HTML.             │     │
│  │  Screen reader associates correctly.              │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
└────────────────────────────────────────────────────────────┘

Related IDs from One Base

┌────────────────────────────────────────────────────────────┐
│  ONE useId, MULTIPLE RELATED ELEMENTS                      │
│                                                            │
│  const id = useId();  // base: ":r0:"                      │
│                                                            │
│  ┌──────────────────────────────────────────────────┐     │
│  │  <label for=":r0:">Password:</label>              │     │
│  │  <input id=":r0:"                                 │     │
│  │         aria-describedby=":r0:-hint :r0:-error" />│     │
│  │  <p id=":r0:-hint">At least 18 characters.</p>    │     │
│  │  <p id=":r0:-error" role="alert">Too short.</p>   │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  The input is described by both the hint and the error.    │
│  All IDs are derived from one base.                        │
│  One useId call serves the entire field.                   │
│                                                            │
└────────────────────────────────────────────────────────────┘

What useId Is Not For

┌────────────────────────────────────────────────────────────┐
│  useId SCOPE: WHAT IT IS AND IS NOT FOR                    │
│                                                            │
│  FOR (DOM relationships)                                   │
│  ┌──────────────────────────────────────────────────┐     │
│  │  htmlFor / id          Label to input             │     │
│  │  aria-describedby      Input to hint/error        │     │
│  │  aria-labelledby       Group to heading           │     │
│  │  aria-controls         Combobox to listbox        │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  NOT FOR                                                   │
│  ┌──────────────────────────────────────────────────┐     │
│  │  List keys             Use data.id                │     │
│  │  Cache keys            Use data-derived key       │     │
│  │  Database IDs          Use UUID library           │     │
│  │  Analytics events      Use dedicated generator    │     │
│  │  Persistent identity   ID may change on remount   │     │
│  └──────────────────────────────────────────────────┘     │
│                                                            │
│  The hook is narrow because its guarantees are narrow:     │
│  unique per instance, stable across renders, matching      │
│  across server and client. Nothing more.                   │
│                                                            │
└────────────────────────────────────────────────────────────┘

Summary

ItemValue
useIdHook that generates unique, stable ID strings
ParametersNone
ReturnsUnique string per component instance
PurposeAccessibility attributes that link DOM elements
StabilityStable across renders for component lifetime
SSR-safeIDs match between server and client
Tree-basedDerived from component’s parent path
identifierPrefixOption to namespace IDs for multiple roots
List keysNot for this; use data-derived keys
Cache keysNot for this; use data-derived keys
Async Server ComponentsCurrently not supported

Key takeaways:

  • useId solves the duplicate ID problem in reusable components. When a component renders an input and a label with a hardcoded id, rendering that component twice produces invalid HTML. useId generates a unique ID per instance, so the component can be reused without conflict.
  • Stability across renders and SSR is the core value. Random IDs change on every render and mismatch between server and client. Counters depend on render order and break under concurrent rendering. useId is stable and reproducible from the tree structure.
  • The hook is narrow on purpose. It is for DOM relationships, not for list keys, cache keys, database identifiers, or analytics events. Using it for those purposes leads to bugs that are difficult to diagnose.
  • The generated ID contains a colon. This is valid in HTML but invalid in CSS selectors. Add a prefix if you need to use the ID in a stylesheet.
  • Server and client must render the same tree. If the trees differ on the first render, the IDs differ and hydration fails. Conditional rendering that depends on browser state must be handled carefully.
  • Multiple apps need identifierPrefix. When two React roots share a page, each needs its own prefix to avoid ID collisions. The prefix must be passed to both createRoot and the server renderer.
  • Related IDs should share a base. Call useId once, then append suffixes for the hint, error, list, or other related elements. This reduces hook calls and makes the relationships explicit.
  • The rules of hooks apply. useId cannot be called conditionally. If a component needs an ID only in some branches, extract the branch into its own component.

Remember: useId is the React solution to a problem that every reusable component faces: how to generate a unique identifier that is stable across renders and matches between server and client. It is not a general-purpose ID generator. It is specifically for linking DOM elements for accessibility, and its guarantees are designed around that use case. The ID is derived from the component’s position in the tree, so it is reproducible on the server and the client as long as the trees match. Use it for htmlFor, id, aria-describedby, aria-labelledby, and aria-controls. Do not use it for list keys, cache keys, or anything that must persist beyond the component’s mounted lifetime. When you render multiple React apps on one page, pass a distinct identifierPrefix to each. The hook is small, but getting it right is what keeps accessible forms accessible when components are reused.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!