useId for stable, SSR-safe unique ids
Published March 30, 2027
import { useId } from "react";
export const PasswordField = () => {
const id = useId();
return (
<div>
<label htmlFor={id}>Password</label>
<input id={id} type="password" aria-describedby={`${id}-hint`} />
<p id={`${id}-hint`}>Must be at least 12 characters.</p>
</div>
);
};What
useId generates a unique ID string that's stable across a component's lifetime and, critically, identical between the server-rendered HTML and the client's first render.
Why it matters
Associating a label with an input, or a hint with aria-describedby, needs an id — and if that id is hardcoded, rendering the same component twice on one page creates a duplicate, breaking the association for both. The instinctive fix, a module-level counter or Math.random(), breaks under server rendering: the server and the client each generate their own sequence, so the id baked into the server's HTML doesn't match the one the client computes on hydration, and React logs a mismatch warning (or worse, the association silently fails). useId is deterministic per component position in the tree, so server and client agree without coordination.
How it works
useId()takes no arguments and returns one ID per call, scoped to this component instance.- Calling it once and deriving related ids from it (
`${id}-hint`) is the recommended pattern when a component needs several linked ids, rather than callinguseId()multiple times for what's conceptually one identifier namespace. htmlFor/idlinks the<label>to the<input>;aria-describedby/idlinks the hint text — both rely on the ids matching exactly, whichuseIdguarantees without either side hardcoding a string.
Gotchas
- Don't use
useIdfor list keys — it produces one id per call, not per array item, and reusing it across a.map()produces duplicate keys. - The generated string (something like
«r1») is intentionally not a clean, short token — don't parse it or rely on its format, only its stability and uniqueness. - If multiple independent React roots render on the same page, pass a distinct
identifierPrefixto eachcreateRoot/hydrateRootcall so their generated ids can't collide.
Related
crypto.randomUUID()— fine for ids that never touch SSR/hydration (e.g., a key for a purely client-side list built after mount), but wrong for anything that needs to match between server and client render.aria-labelledby— an alternative toaria-describedby/htmlForworth knowing for cases where the "label" is more complex markup than plain text.