Skip to main content

What is useId hook in React?

useId is a React 18 hook that generates a stable, unique ID guaranteed to match between server and client rendering.

Theory

TL;DR

  • useId produces IDs like :r0:, :r1: using React's internal counter, not randomness
  • Main difference from Math.random(): server and client always produce the exact same value
  • Use it for htmlFor/id pairs and aria-* attributes in SSR apps; skip it for pure client-side code
  • Never call it inside .map() - call once per component, then append stable data

Quick example

tsx
import { useId } from 'react'; function EmailInput() { const id = useId(); // Same ":r0:" on server and client return ( <> <label htmlFor={id}>Email</label> <input id={id} type="email" /> </> ); }

One call, one stable ID. Server renders :r0:, browser hydrates with :r0:. No mismatch warning.

Why random IDs break SSR

When React renders on the server, it outputs HTML. The browser then receives that HTML and React hydrates it - attaching event listeners and reconciling the virtual DOM. If any attribute value differs between server HTML and what React generates client-side, you get a hydration mismatch warning.

Math.random() gives "0.123..." on the server and "0.456..." on the client. Different values break the htmlFor/id link. Screen readers lose the label-to-input association. useId avoids this because React's counter runs the same sequence on both sides.

How it works

React keeps a global counter per component tree root. During SSR it increments that counter as it renders, then serializes the state. On the client, hydrateRoot starts from the same index and replays - so every useId call lands on the same number it did on the server.

The colon wrapping (:r1:) is intentional. Colons have special meaning in CSS (pseudo-classes, pseudo-elements), so an ID like :r0: cannot be accidentally targeted by a stylesheet. These IDs are for accessibility linking, not CSS.

When to use

  • htmlFor/id pairs in forms with SSR
  • aria-labelledby and aria-describedby in accessible components (tooltips, modals, descriptions)
  • Multiple IDs in one component - call useId() once, then append suffixes: ${id}-email, ${id}-password

Skip it for pure client-side apps. Any unique string works there. Skip it for data-testid too - just hardcode "email-input" in tests. Using useId for test attributes adds React allocator overhead with no benefit.

Common mistakes

Calling useId inside .map()

tsx
// ❌ New allocator slot on every render - IDs change {users.map(u => <input key={u.id} id={useId()} />)} // ✅ Call once per component, append stable data function UserInput({ user }) { const baseId = useId(); return <input id={`${baseId}-${user.id}`} />; }

Each useId() call inside a loop opens a new slot on every render. Server and client counters fall out of sync.

Using it for list keys

tsx
// ❌ Wrong {items.map(item => <li key={useId()}>...</li>)} // ✅ Use data from the item itself {items.map(item => <li key={item.id}>...</li>)}

Keys must be stable across renders. useId inside a map produces different IDs each time.

Assuming global uniqueness across multiple roots

IDs are scoped per root. Portals and separate ReactDOM.createRoot calls each have their own counter, so :r1: can appear in more than one tree. If they share the DOM, add a manual prefix: useId() + '-modal'.

Real-world usage

  • Material-UI v6+: input aria-describedby links generated internally with useId
  • React Aria (Adobe): all form label associations use useId
  • Chakra UI: modal aria-labelledby relies on it
  • Next.js App Router: server components get stable form field IDs without extra config

Follow-up questions

Q: Why does the ID use colons like :r0:?
A: Colons avoid CSS conflicts. An ID starting with : cannot be selected with a plain CSS rule, which is intentional - these IDs exist for accessibility, not styling.

Q: What happens with multiple roots or portals?
A: Each root has its own counter. If two roots share the DOM, their IDs can overlap. Add a manual prefix to prevent collisions.

Q: Does React StrictMode break useId?
A: No. StrictMode double-invokes effects in development but preserves the ID counter for hydration. Production SSR is unaffected.

Q: Can I seed or customize the generated IDs?
A: No public API exists for that. If you need readable IDs in tests, use hardcoded data-testid values instead.

Q: (Senior) How does useId interact with Suspense boundaries in streaming SSR?
A: Each Suspense boundary tracks the ID counter independently. When a lazy subtree streams in, it picks up the correct sequence. Mismatches happen only if you violate hook call order inside a boundary.

Examples

Login form with label-input pairs

tsx
import { useId } from 'react'; function LoginForm() { const id = useId(); // One base ID for the whole form return ( <form> <label htmlFor={`${id}-email`}>Email</label> <input id={`${id}-email`} type="email" /> <label htmlFor={`${id}-password`}>Password</label> <input id={`${id}-password`} type="password" /> </form> ); }

One useId call gives a base like :r3:. Both fields get stable IDs (:r3:-email, :r3:-password) without a second hook call. In my experience, this suffix pattern is cleaner than calling useId twice - fewer allocator slots, same result.

Accessible tooltip with aria-describedby

tsx
import { useId } from 'react'; function Tooltip({ text, children }: { text: string; children: React.ReactNode }) { const id = useId(); return ( <div> <div aria-describedby={id}>{children}</div> <div role="tooltip" id={id}>{text}</div> </div> ); }

aria-describedby on the trigger and id on the tooltip need to match for assistive technology to read the description. On a server-rendered page, useId guarantees that match without any manual coordination.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?