AdSense Leaderboard

Google AdSense Placeholder

HEADER SLOT

useId: Generating Stable Unique IDs in React 19

Last updated:
useId: Generating Stable Unique IDs in React 19

Master useId for creating stable, unique identifiers in React 19. Learn accessibility patterns, SSR compatibility, and best practices with complete code examples.

# useId: Generating Stable Unique IDs in React 19

If you've ever hardcoded IDs into your React components, you've probably run into this problem: the component renders twice on the same page, and suddenly your IDs aren't unique anymore. Screen readers get confused, form labels don't connect to inputs, ARIA attributes break, and everything feels fragile. That's where useId comes in.

Introduced in React 18 and solidified in React 19, useId is a deceptively simple hook that generates unique, stable IDs for you. It handles SSR (server-side rendering) seamlessly, ensures IDs stay consistent across renders and page reloads, and eliminates the entire class of "duplicate ID" bugs. For developers building accessible, production-ready React apps, this hook is essential.

# Table of Contents

  1. What is useId?
  2. The Problem It Solves
  3. Basic Syntax and Usage
  4. Core Use Cases
  5. Server-Side Rendering (SSR)
  6. Common Pitfalls
  7. Advanced Patterns
  8. FAQ

# What is useId?

useId is a React hook that generates a unique string ID for each component instance. Unlike approaches like Math.random() or global counters, useId produces IDs that are:

  • Stable: The same ID across every render of that component instance
  • Unique: Different for each instance, even if the component renders multiple times on the page
  • SSR-Safe: Consistent between server and client rendering (no hydration mismatches)
  • Deterministic: Generated from the component's position in the React tree, not from randomness

Here's the signature:

typescript
const id = useId()

It takes no arguments and returns a string like :r1: or :r2f: (the exact format is internal to React and should be treated as opaque).

# The Problem It Solves

Let's see why useId matters with a real example.

# TypeScript Version (Without useId)

typescript
// ❌ Bad approach - hardcoded IDs
function PasswordField() {
  return (
    <>
      <label htmlFor="password-input">Password:</label>
      <input
        id="password-input"
        type="password"
        aria-describedby="password-hint"
      />
      <p id="password-hint">
        Must contain at least 18 characters
      </p>
    </>
  );
}

// Now use it twice on the same page:
export function App() {
  return (
    <>
      <h2>Choose password</h2>
      <PasswordField /> {/* id="password-input" */}
      
      <h2>Confirm password</h2>
      <PasswordField /> {/* DUPLICATE: id="password-input" */}
    </>
  );
}

Problem: The page now has two elements with id="password-input". Browser behavior is undefined. Screen readers might associate the second label with the first input. ARIA attributes break. This is a silent, subtle bug that's hard to catch in testing.

# TypeScript Version (With useId)

typescript
import { useId } from 'react';

function PasswordField() {
  const passwordId = useId();
  const hintId = useId();

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

// Use it multiple times:
export function App() {
  return (
    <>
      <h2>Choose password</h2>
      <PasswordField /> {/* id=":r1:" */}
      
      <h2>Confirm password</h2>
      <PasswordField /> {/* id=":r2:" - automatically unique! */}
    </>
  );
}

Result: Each component instance gets completely unique, stable IDs. No collisions. Screen readers work. ARIA attributes connect correctly.

# Basic Syntax and Usage

# Step 1: Import useId

typescript
import { useId } from 'react';

# Step 2: Call it in Your Component

typescript
function MyForm() {
  const inputId = useId();
  const errorId = useId();

  return (
    <div>
      <label htmlFor={inputId}>Email:</label>
      <input id={inputId} type="email" aria-describedby={errorId} />
      <span id={errorId} role="alert">Please enter a valid email</span>
    </div>
  );
}

# Step 3: Use the IDs for Accessibility

  • Connect labels to inputs via htmlFor
  • Link error messages via aria-describedby
  • Establish relationships via aria-labelledby

That's it. React handles the uniqueness, stability, and SSR consistency automatically.

# Core Use Cases

# Use Case 1: Form Labels and Inputs (The Classic)

This is the #1 use case for useId. Form accessibility requires unique ID associations between labels and inputs.

# TypeScript Version

typescript
import { useId } from 'react';

interface FormFieldProps {
  label: string;
  placeholder?: string;
  error?: string;
  type?: string;
}

export function FormField({
  label,
  placeholder,
  error,
  type = 'text'
}: FormFieldProps) {
  const inputId = useId();
  const errorId = useId();

  return (
    <div className="form-field">
      <label htmlFor={inputId}>{label}</label>
      <input
        id={inputId}
        type={type}
        placeholder={placeholder}
        aria-describedby={error ? errorId : undefined}
        aria-invalid={Boolean(error)}
        className={error ? 'input-error' : ''}
      />
      {error && (
        <span id={errorId} role="alert" className="error-message">
          {error}
        </span>
      )}
    </div>
  );
}

// Usage - works perfectly when used multiple times
export function LoginForm() {
  const [email, setEmail] = '';
  const [password, setPassword] = '';
  const [errors, setErrors] = '';

  return (
    <form onSubmit={(e) => e.preventDefault()}>
      <FormField
        label="Email"
        type="email"
        placeholder="your@email.com"
        error={errors.email}
      />
      <FormField
        label="Password"
        type="password"
        error={errors.password}
      />
      <button type="submit">Log In</button>
    </form>
  );
}

# JavaScript Version

javascript
import { useId } from 'react';

export function FormField({
  label,
  placeholder,
  error,
  type = 'text'
}) {
  const inputId = useId();
  const errorId = useId();

  return (
    <div className="form-field">
      <label htmlFor={inputId}>{label}</label>
      <input
        id={inputId}
        type={type}
        placeholder={placeholder}
        aria-describedby={error ? errorId : undefined}
        aria-invalid={Boolean(error)}
        className={error ? 'input-error' : ''}
      />
      {error && (
        <span id={errorId} role="alert" className="error-message">
          {error}
        </span>
      )}
    </div>
  );
}

export function LoginForm() {
  const [email, setEmail] = '';
  const [password, setPassword] = '';
  const [errors, setErrors] = '';

  return (
    <form onSubmit={(e) => e.preventDefault()}>
      <FormField
        label="Email"
        type="email"
        placeholder="your@email.com"
        error={errors.email}
      />
      <FormField
        label="Password"
        type="password"
        error={errors.password}
      />
      <button type="submit">Log In</button>
    </form>
  );
}

# Use Case 2: ARIA Attributes (Accessibility Relationships)

Beyond form labels, useId is critical for establishing accessibility relationships between separate DOM elements.

# TypeScript Version

typescript
import { useId } from 'react';

interface TabsProps {
  tabs: Array<{ label: string; content: React.ReactNode }>;
}

export function Tabs({ tabs }: TabsProps) {
  const [activeTab, setActiveTab] = '';
  const tabListId = useId();

  return (
    <div>
      <div role="tablist" id={tabListId}>
        {tabs.map((tab, idx) => (
          <button
            key={idx}
            role="tab"
            aria-selected={activeTab === idx}
            aria-controls={`panel-${idx}`}
            onClick={() => setActiveTab(idx)}
          >
            {tab.label}
          </button>
        ))}
      </div>

      {tabs.map((tab, idx) => (
        <div
          key={idx}
          id={`panel-${idx}`}
          role="tabpanel"
          aria-labelledby={tabListId}
          hidden={activeTab !== idx}
        >
          {tab.content}
        </div>
      ))}
    </div>
  );
}

# JavaScript Version

javascript
import { useId } from 'react';

export function Tabs({ tabs }) {
  const [activeTab, setActiveTab] = '';
  const tabListId = useId();

  return (
    <div>
      <div role="tablist" id={tabListId}>
        {tabs.map((tab, idx) => (
          <button
            key={idx}
            role="tab"
            aria-selected={activeTab === idx}
            aria-controls={`panel-${idx}`}
            onClick={() => setActiveTab(idx)}
          >
            {tab.label}
          </button>
        ))}
      </div>

      {tabs.map((tab, idx) => (
        <div
          key={idx}
          id={`panel-${idx}`}
          role="tabpanel"
          aria-labelledby={tabListId}
          hidden={activeTab !== idx}
        >
          {tab.content}
        </div>
      ))}
    </div>
  );
}

# Server-Side Rendering (SSR) Compatibility

One of useId's superpowers is SSR compatibility. This is where it truly shines over other approaches.

# The Problem Without useId

javascript
// ❌ Bad - causes hydration mismatch
let counter = 0;
function PasswordField() {
  const id = `password-${counter++}`; // Server: password-0, password-1
  // Client during hydration: password-0, password-1 (but order might differ!)
  return (
    <input id={id} type="password" />
  );
}

If the server and client render components in a different order (which can happen with concurrent rendering), the IDs won't match. React will throw hydration warnings or completely re-render the subtree.

# The Solution with useId

typescript
import { useId } from 'react';

function PasswordField() {
  const id = useId(); // Server: ":r1:", Client hydration: ":r1:" ✅
  return (
    <input id={id} type="password" />
  );
}

React generates IDs based on the component's position in the React tree, not render order. Server and client always match, regardless of rendering order or concurrent features.

# Common Pitfalls

# Pitfall 1: Using useId for List Keys

This is the most common mistake:

typescript
// ❌ WRONG - don't do this
function ItemList({ items }) {
  return (
    <ul>
      {items.map((item) => (
        <li key={useId()}>{item.name}</li>
      ))}
    </ul>
  );
}

Why? If a component unmounts and remounts, useId generates a new ID. For list keys, you need IDs that come from your data.

typescript
// ✅ Correct - use data-based IDs for keys
function ItemList({ items }) {
  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

# Pitfall 2: Using useId for CSS Selectors

The IDs generated by useId have an unpredictable format. Don't rely on them in CSS:

typescript
// ❌ Bad - ID format is internal and unstable
const id = useId();
// Later: document.querySelector(`#${id}`) ❌

// ✅ Better - use useRef for DOM access
const ref = useRef<HTMLInputElement>(null);
// Later: ref.current?.focus() ✅

# Pitfall 3: Calling useId Conditionally

Hooks must be called unconditionally:

typescript
// ❌ Bad - calling useId in a condition
function Field({ disabled }: { disabled: boolean }) {
  if (!disabled) {
    const id = useId(); // ❌ Hook called conditionally
  }
  return <input />;
}

// ✅ Good - always call useId
function Field({ disabled }: { disabled: boolean }) {
  const id = useId(); // Always called
  return <input id={disabled ? undefined : id} />;
}

# Pitfall 4: Exposing useId-Generated IDs in APIs

In Chinese apps (e.g., Alibaba dashboard components), developers sometimes expose component IDs to users. Don't do this:

typescript
// ❌ Bad - exposing internal ID to user
export function MyComponent() {
  const id = useId();
  return {
    id, // Don't expose this!
    element: <input id={id} />
  };
}

// ✅ Better - let consumers use useId themselves
export function MyComponent({ id }: { id: string }) {
  return <input id={id} />;
}

# Advanced Patterns

# Pattern 1: Multiple IDs in a Complex Component

Sometimes you need multiple unique IDs in a single component:

typescript
import { useId } from 'react';

export function SearchBox() {
  const searchInputId = useId();
  const clearButtonId = useId();
  const suggestionsId = useId();

  return (
    <div>
      <input
        id={searchInputId}
        type="search"
        aria-controls={suggestionsId}
      />
      <button
        id={clearButtonId}
        aria-label="Clear search"
      >

      </button>
      <ul id={suggestionsId} role="listbox">
        {/* Suggestions */}
      </ul>
    </div>
  );
}

# Pattern 2: Custom Hooks with useId

Encapsulate ID logic in custom hooks:

typescript
import { useId } from 'react';

export function useFormField(name: string) {
  const inputId = useId();
  const errorId = useId();
  const descriptionId = useId();

  return {
    inputId,
    errorId,
    descriptionId,
    getLabelProps: () => ({ htmlFor: inputId }),
    getInputProps: () => ({
      id: inputId,
      'aria-describedby': [errorId, descriptionId].join(' ')
    }),
    getErrorProps: () => ({ id: errorId, role: 'alert' }),
    getDescriptionProps: () => ({ id: descriptionId })
  };
}

// Usage
function LoginForm() {
  const email = useFormField('email');
  const password = useFormField('password');

  return (
    <form>
      <label {...email.getLabelProps()}>Email</label>
      <input {...email.getInputProps()} type="email" />
      <span {...email.getErrorProps()}>Invalid email</span>

      <label {...password.getLabelProps()}>Password</label>
      <input {...password.getInputProps()} type="password" />
    </form>
  );
}

# Pattern 3: useId with Portal Modals

useId works correctly even when components render into portals:

typescript
import { useId } from 'react';
import { createPortal } from 'react-dom';

export function Modal({ title, children }: { title: string; children: React.ReactNode }) {
  const titleId = useId();

  return createPortal(
    <div role="dialog" aria-labelledby={titleId}>
      <h2 id={titleId}>{title}</h2>
      {children}
    </div>,
    document.getElementById('modal-root')!
  );
}

React ensures uniqueness across the entire React tree, not just the DOM tree.

# FAQ

# Q: Does useId work on the server?

A: Yes, that's actually one of its main advantages. When using Next.js, Remix, or any SSR framework, useId ensures the same IDs are generated on both server and client, preventing hydration mismatches.

# Q: Can I use useId with TypeScript without type errors?

A: Absolutely. useId returns a string, which is simple to type:

typescript
const id: string = useId();

No special typing needed.

# Q: What if I need a truly random ID instead of a stable one?

A: Use crypto.randomUUID() or a library like uuid:

typescript
import { v4 as uuid } from 'uuid';

// For random IDs, not stable accessibility IDs
const randomId = uuid();

But for accessibility and ARIA relationships, useId is the right tool.

# Q: How does useId work with Suspense and concurrent rendering?

A: It just works. React generates IDs based on the component's position in the fiber tree, which is stable even during concurrent rendering. This was one of the main motivations for creating useId.

# Q: Is useId available in React 18 and earlier versions?

A: useId was introduced in React 18. If you're on an older version, you can use Reach UI's useId (which pre-dates React's version) or upgrade to React 18+.

# Q: Should I use useId for modal trigger buttons and the modal itself?

A: Yes, if you're linking them with ARIA:

typescript
const modalId = useId();
const triggerId = useId();

return (
  <>
    <button id={triggerId} aria-controls={modalId}>
      Open Dialog
    </button>
    <Modal id={modalId} />
  </>
);

# Q: Can multiple components share the same useId-generated ID?

A: No, each component instance gets its own unique ID. This is the entire point of the hook.

A: Let the parent generate the IDs and pass them down:

typescript
function Parent() {
  const childId = useId();
  return <Child id={childId} />;
}

function Child({ id }: { id: string }) {
  return <input id={id} />;
}

Or let each component generate its own if they're independent:

typescript
function Parent() {
  return <Child />; // Child generates its own ID
}

function Child() {
  const id = useId();
  return <input id={id} />;
}

Related Articles:

Questions? Are you using useId in your accessibility-critical components? Or have you struggled with ID collisions before discovering this hook? Share your experience in the comments—I'd love to hear how it's improved your development workflow!

Sponsored Content

Google AdSense Placeholder

CONTENT SLOT

Sponsored

Google AdSense Placeholder

FOOTER SLOT