
useId: Generating Stable IDs for Accessible Components
Accessible HTML runs on IDs. A <label> points at its input with htmlFor, an input points at its error message with aria-describedby, and a tab points at its panel with aria-controls. Hardcoding id="email" works until you render the same component twice on one page. Then you have duplicate IDs, the second label focuses the first input, and screen readers announce the wrong description.
The usual workarounds all have problems. A module-level counter gives different IDs on the server and client, which breaks hydration. Math.random() or crypto.randomUUID() changes on every render unless you store it, and still mismatches between server and client. React's answer is useId, a hook that generates an ID that's unique within the app, stable across re-renders, and identical on the server and the client.
This post covers how useId works, how to use it to wire up labels and ARIA attributes in reusable components, how to derive several related IDs from one call, and the cases where useId is the wrong tool.
What useId Returns
useId takes no arguments and returns a string:
import { useId } from "react";
function Example() {
const id = useId();
return <p>My id is {id}</p>;
}
A few properties matter:
- Unique per call site and component instance. Two instances of
Exampleget different IDs. TwouseIdcalls in the same component also get different IDs. - Stable across re-renders. The same instance keeps the same ID for its whole lifetime.
- Consistent between server and client. The ID is derived from the component's position in the tree (its "parent path"), not from a counter or randomness. As long as the server and client render the same tree, they produce the same IDs, so hydration matches.
- Opaque. The exact format has changed between React versions, so treat it as a black box. Don't parse it, and don't depend on what characters it contains.
The Basic Pattern: Label and Input
Here's a reusable text field that would break with a hardcoded ID:
import { useId } from "react";
type TextFieldProps = {
label: string;
type?: "text" | "email" | "password";
name: string;
};
export function TextField({ label, type = "text", name }: TextFieldProps) {
const id = useId();
return (
<div className="field">
<label htmlFor={id}>{label}</label>
<input id={id} name={name} type={type} />
</div>
);
}
Now you can render it as many times as you like:
export function SignupForm() {
return (
<form>
<TextField label="Email" type="email" name="email" />
<TextField label="Password" type="password" name="password" />
<TextField label="Confirm password" type="password" name="confirm" />
</form>
);
}
Each field gets its own ID, each label focuses the right input, and screen readers announce the correct label. Notice that name and id are separate concerns: name is what the form submits, id is only for linking elements together in the DOM.
Deriving Multiple IDs From One Call
Most accessible components need more than one ID. A field with a hint and an error message needs three: the input, the hint, and the error. You could call useId three times, but the recommended pattern is to call it once and add suffixes:
import { useId } from "react";
type FieldProps = {
label: string;
name: string;
hint?: string;
error?: string;
};
export function Field({ label, name, hint, error }: FieldProps) {
const id = useId();
const inputId = `${id}-input`;
const hintId = `${id}-hint`;
const errorId = `${id}-error`;
const describedBy = [hint && hintId, error && errorId].filter(Boolean).join(" ");
return (
<div className="field">
<label htmlFor={inputId}>{label}</label>
<input
id={inputId}
name={name}
aria-invalid={error ? true : undefined}
aria-describedby={describedBy || undefined}
/>
{hint && <p id={hintId}>{hint}</p>}
{error && (
<p id={errorId} role="alert">
{error}
</p>
)}
</div>
);
}
Why one call with suffixes? It keeps related IDs visibly grouped, it's cheaper to read, and you can't accidentally mix up which useId result belongs to which element. aria-describedby accepts a space-separated list of IDs, so the input can point at both the hint and the error at once.
Setting attributes to undefined when they don't apply is deliberate. React omits undefined attributes entirely, so you don't render an empty aria-describedby="".
Allowing an ID Override
Sometimes the consumer needs a specific ID, for example to link to the field from a "skip to field" link or an anchor in an error summary. A common pattern is to accept an optional id prop and fall back to the generated one:
import { useId, type ComponentProps } from "react";
type CheckboxProps = Omit<ComponentProps<"input">, "type"> & {
label: string;
};
export function Checkbox({ id, label, ...rest }: CheckboxProps) {
const generatedId = useId();
const inputId = id ?? generatedId;
return (
<div className="checkbox">
<input id={inputId} type="checkbox" {...rest} />
<label htmlFor={inputId}>{label}</label>
</div>
);
}
useId must still be called unconditionally, because hooks can't be called conditionally. The cost is negligible. If you're unsure why that rule exists, the rules of hooks explained walks through it.
Real-World Example: An Accessible Disclosure
A disclosure (a button that shows and hides a region) needs the button to reference the panel with aria-controls, and ideally the panel to reference the button with aria-labelledby:
import { useId, useState, type ReactNode } from "react";
type DisclosureProps = {
title: string;
children: ReactNode;
defaultOpen?: boolean;
};
export function Disclosure({ title, children, defaultOpen = false }: DisclosureProps) {
const [open, setOpen] = useState(defaultOpen);
const id = useId();
const buttonId = `${id}-button`;
const panelId = `${id}-panel`;
return (
<div>
<h3>
<button
id={buttonId}
type="button"
aria-expanded={open}
aria-controls={panelId}
onClick={() => setOpen((o) => !o)}
>
{title}
</button>
</h3>
<div id={panelId} role="region" aria-labelledby={buttonId} hidden={!open}>
{children}
</div>
</div>
);
}
Ten of these on a page will never collide. This same structure scales up to tabs, comboboxes, and menus, which is how headless UI libraries handle IDs internally.
Sharing an ID Through Compound Components
In a compound component, the parent generates the ID and children read it from context. This keeps the consumer's markup clean:
import { createContext, useContext, useId, type ComponentProps, type ReactNode } from "react";
const FieldContext = createContext<string | null>(null);
function useFieldId() {
const id = useContext(FieldContext);
if (!id) throw new Error("Field parts must be used inside <FormField>");
return id;
}
export function FormField({ children }: { children: ReactNode }) {
const id = useId();
return (
<FieldContext value={id}>
<div className="field">{children}</div>
</FieldContext>
);
}
export function FieldLabel({ children }: { children: ReactNode }) {
const id = useFieldId();
return <label htmlFor={`${id}-input`}>{children}</label>;
}
export function FieldInput(props: ComponentProps<"input">) {
const id = useFieldId();
return <input id={`${id}-input`} aria-describedby={`${id}-desc`} {...props} />;
}
export function FieldDescription({ children }: { children: ReactNode }) {
const id = useFieldId();
return <p id={`${id}-desc`}>{children}</p>;
}
Usage reads like plain HTML, with no IDs in sight:
<FormField>
<FieldLabel>Username</FieldLabel>
<FieldInput name="username" />
<FieldDescription>3 to 20 characters, letters and numbers only.</FieldDescription>
</FormField>
This example uses React 19's <FieldContext value={...}> syntax, which renders a context directly as a provider. On React 18 you'd write <FieldContext.Provider value={...}>. For more on this pattern, see the compound components pattern in React.
Why Not a Counter or Random ID?
It's worth understanding exactly what goes wrong with the alternatives, because you'll see them in older codebases.
// Broken: module-level counter
let counter = 0;
function BadField({ label }: { label: string }) {
const id = `field-${counter++}`;
return (
<>
<label htmlFor={id}>{label}</label>
<input id={id} />
</>
);
}
This fails in several ways. The ID changes on every re-render, because the counter increments each time. On the server, the counter keeps climbing across requests, so the first visitor sees field-0 and the hundredth sees field-4000. On the client, the counter starts at zero again, so hydration finds different attributes than the server sent. In Strict Mode, React renders components twice in development, so the counter skips numbers.
Random IDs fail the same hydration test: the server and client generate different random values. Storing the random value in useState fixes re-renders but not hydration.
useId avoids all of this because it computes the ID from the tree position, which is the same on both sides.
Multiple React Roots: identifierPrefix
If you mount more than one React app on the same page, for example a widget embedded into a page that already has its own React root, the two roots can generate overlapping IDs. Both createRoot and hydrateRoot accept an identifierPrefix option:
import { createRoot } from "react-dom/client";
import { App } from "./App";
import { Widget } from "./Widget";
createRoot(document.getElementById("app")!, {
identifierPrefix: "app-",
}).render(<App />);
createRoot(document.getElementById("chat-widget")!, {
identifierPrefix: "widget-",
}).render(<Widget />);
When server rendering, pass the same prefix to the server render call (such as renderToPipeableStream or prerender) and to hydrateRoot, so both sides agree.
What Not to Use useId For
useId solves one problem: linking DOM elements together. It's a poor fit for several things people try to use it for.
- List keys. Keys should come from your data, like a database ID. A
useIdinside a list item is generated after the key is needed, and calling it in the parent for each item isn't possible because hooks can't be called in loops. See why keys matter when rendering lists. - Database or entity IDs. If you're creating a new todo item on the client, use
crypto.randomUUID()in the event handler.useIdvalues are only unique within one render tree, not globally. - CSS selectors. Older React versions produced IDs containing colons, which need escaping in
querySelector. Even with newer formats, styling by generated ID is fragile. Use classes or data attributes for styling, and refs to reach DOM nodes. - Anything that must survive a reload. The ID depends on tree structure. Change the tree and the ID changes.
Common Mistakes With useId
- Using one ID for several elements. Every element needs a unique
id. Derive variants with suffixes like${id}-hintinstead of reusing the same value. - Calling
useIdconditionally. It's a hook, so it must run on every render in the same order. Call it at the top level and decide later whether to use it. - Rendering different trees on server and client. If a component renders only on the client (for example behind a
typeof windowcheck), the tree positions shift and IDs no longer match. Fix the hydration mismatch itself rather than working around the ID. - Using it to look up elements. Calling
document.getElementById(id)works, but arefis simpler, type-safe, and doesn't depend on the DOM tree. - Forgetting
aria-describedbyaccepts multiple IDs. Join them with spaces instead of picking one.
Frequently Asked Questions (FAQ) About useId
No. It's unique within one React root, which is enough for linking DOM elements on a page. If you have several roots on the same page, set a different identifierPrefix on each. For globally unique values like database keys, use crypto.randomUUID().
No. Keys should come from your data so React can match items between renders. Hooks also can't be called inside a loop, so you couldn't generate one per item in the parent anyway. Use a stable ID from each item.
A counter depends on how many times code has run, which differs between the server and the browser. useId derives its value from the component's position in the tree. Since the server and client render the same tree, they produce the same IDs, and hydration matches.
Once is usually cleaner. Generate a base ID and append suffixes like -input, -hint, and -error. Calling it several times also works and produces distinct values, so it's a matter of style.
No. The ID stays the same for the lifetime of the component instance. It only changes if the component unmounts and mounts again, or if its position in the tree changes.
It's an opaque string, and the format has changed between React versions. Don't rely on specific characters or parse it. Treat it as a value you pass to id, htmlFor, and ARIA attributes, nothing more.
Conclusion
useId gives every component instance a stable, unique, hydration-safe ID with one line of code. Use it to connect labels to inputs, inputs to hints and errors, and buttons to the panels they control. Call it once, derive related IDs with suffixes, accept an optional id prop when consumers need control, and set identifierPrefix when multiple React roots share a page.
As a next step, audit your shared form components for hardcoded IDs and replace them with useId. Then run a screen reader or the accessibility tree in your browser's DevTools over a page with several instances, and confirm each label and description points to the right element.


