An account manager opened an overdue invoice and, from the panel beside it, started a reminder to the customer. She clicked the reminder’s label, Note to customer, and the cursor appeared in the invoice’s own note beside it. A colleague who uses a screen reader had it worse: the reminder’s box read out the invoice’s hint, “Printed above the amount due”.
Both boxes came from the same component, and the component had its id written into it. Two copies on one page meant two elements with one id, and the browser resolves an id to the first element that has it.
useId gives each call of a component its own id, one that is unique on the page and the same in the server’s HTML and in the browser. It is what ties a label, a hint and an error to their control when the component may appear anywhere, any number of times.
An id that every copy shares
The note field was written once, for the invoice, with its ids spelled out:
// Avoid: one id for every copy of the component on the page
export function NoteField({
label,
hint,
}: {
label: string;
hint: string;
}) {
return (
<div>
<label htmlFor="customer-note">{label}</label>
<p id="customer-note-hint">{hint}</p>
<textarea
id="customer-note"
aria-describedby="customer-note-hint"
/>
</div>
);
}On the invoice alone it is correct. The label points at the box, and the box is described by its hint. The reminder panel reused the component, and the page now held two customer-note elements. A label’s for and an aria-describedby each resolve to the first element with the id, so both labels focused the invoice’s box and both boxes read the invoice’s hint.
The quick repair was a counter, so each copy would number itself:
// Avoid: a counter that the server and the browser count differently
let nextId = 0;
export function CountedNoteField({
label,
hint,
}: {
label: string;
hint: string;
}) {
const [id] = useState(() => `note-${(nextId += 1)}`);
return (
<div>
<label htmlFor={id}>{label}</label>
<p id={`${id}-hint`}>{hint}</p>
<textarea id={id} aria-describedby={`${id}-hint`} />
</div>
);
}The copies stopped colliding in the browser, and the page broke somewhere less visible. The server is one long-lived process, so the counter carries on from request to request: the second visitor’s HTML said note-3 and note-4. The browser started from zero and computed note-1 and note-2. React reported that the attributes didn’t match and, in its own words, that this “won’t be patched up”. The page kept ids its components no longer knew.
Name each field by its place, not by its turn
A counter names fields by the order they render in, and that order is not stable: the server and the browser can render in different orders, and a server never starts again from zero. useId names each call by its place in the component tree instead, from the path of parents above it.
An id should depend on where a field is, never on when it rendered. As long as the server and the browser render the same tree, they reach the same ids without agreeing on anything else. A field that first mounts later, in the browser alone, has no server HTML to match, so React gives it a fresh id of its own.
Wiring the note field with one id
The field asks React for one id and derives the rest from it, so it calls the hook once whatever parts it has.
One id, three uses
// One id from React; each part adds its own suffix.
const id = useId();
const hintId = `${id}-hint`;
const errorId = `${id}-error`;
const describedBy = [hint && hintId, error && errorId]
.filter(Boolean)
.join(" ");The box’s description lists only what is on screen: the hint, the error, both or neither. An aria-describedby that names a missing element is ignored by assistive technology, but it hides a bug, and an empty attribute is no better, so the field leaves it off.
The finished field
// A note for the customer. Its label, hint and error are tied to the
// box by ids unique on the page and the same on server and client.
import { useId } from "react";
interface NoteFieldProps {
label: string;
hint?: string;
error?: string;
defaultValue?: string;
}
export function NoteField({
label,
hint,
error,
defaultValue,
}: NoteFieldProps) {
// One id from React; each part adds its own suffix.
const id = useId();
const hintId = `${id}-hint`;
const errorId = `${id}-error`;
const describedBy = [hint && hintId, error && errorId]
.filter(Boolean)
.join(" ");
return (
<div className="note-field">
<label htmlFor={id}>{label}</label>
{hint ? <p id={hintId}>{hint}</p> : null}
<textarea
id={id}
defaultValue={defaultValue}
aria-invalid={Boolean(error)}
aria-describedby={describedBy || undefined}
/>
{error ? <p id={errorId}>{error}</p> : null}
</div>
);
}The hint sits between the label and the box, where people read it before they type, and the error follows the box. A screen reader announces the label, then the hint and the error as the description. The box is marked invalid only while there is an error to explain it.
Two notes on one page
// The invoice page: the invoice's own note to the customer, and the
// reminder panel beside it, which has one too.
import { NoteField } from "./note-field";
export function InvoicePage({
reminderError,
}: {
reminderError?: string;
}) {
return (
<>
<form aria-label="Invoice INV-2041">
<NoteField
label="Note to customer"
hint="Printed above the amount due"
/>
</form>
<aside aria-label="Send a reminder">
<NoteField
label="Note to customer"
hint="Sent with the reminder email"
error={reminderError}
/>
</aside>
</>
);
}The page renders the field twice and passes no ids at all. Each label focuses its own box, and each box is described by its own hint, which is the account manager’s reminder working as she expected the first time.
Where useId stops
Ids, never keys
react.dev is direct about this: don’t use useId to generate keys in a list; keys come from the data, such as a line’s id. Nor is it a cache key for use, since the id is stable once mounted but may change during rendering. It names elements for the browser and assistive technology, and nothing else.
The shape is not part of the contract
React changed the format of these ids twice during the 19 releases, and the reference page doesn’t document it. Treat the id as opaque. Pass it to id, htmlFor and the ARIA attributes, and reach the element itself through a ref. If code must query by it, build the selector with CSS.escape(id) so a future format can’t break it.
Labels, hints and errors read in order
The field gives every part a programmatic relationship: the label names the box, the hint and the error describe it, and aria-invalid marks it. WCAG asks for exactly that when the information is shown visually, under info and relationships, and an id is how HTML states a relationship between two elements that aren’t nested.
Testing the page and its hydration
The first test reads the page as people do, by role and name. The second renders the page to HTML, hydrates it and checks that React reported nothing and the ids stayed as the server wrote them:
import { act } from "react";
import { hydrateRoot } from "react-dom/client";
import { renderToString } from "react-dom/server";
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
import { InvoicePage } from "./invoice-page";
const note = (region: string) =>
within(
screen.getByRole(
region === "Send a reminder" ? "complementary" : "form",
{
name: region,
},
),
).getByRole("textbox", { name: "Note to customer" });
test("each note has its own label, hint and error", async () => {
const error = "Keep the note under 500 characters";
render(<InvoicePage reminderError={error} />);
const reminder = note("Send a reminder");
expect(reminder).toHaveAccessibleDescription(
`Sent with the reminder email ${error}`,
);
expect(note("Invoice INV-2041")).toHaveAccessibleDescription(
"Printed above the amount due",
);
const [, reminderLabel] = screen.getAllByText("Note to customer");
if (reminderLabel) await userEvent.click(reminderLabel);
expect(reminder).toHaveFocus();
});
test("hydrates the server's HTML with the same ids", async () => {
const container = document.createElement("div");
container.innerHTML = renderToString(<InvoicePage />);
document.body.append(container);
const served = [...container.querySelectorAll("textarea")].map(
(box) => box.id,
);
const onRecoverableError = vi.fn();
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => {});
const root = await act(async () =>
hydrateRoot(container, <InvoicePage />, { onRecoverableError }),
);
expect(onRecoverableError).not.toHaveBeenCalled();
expect(consoleError).not.toHaveBeenCalled();
expect(new Set(served).size).toBe(2);
const hydrated = [...container.querySelectorAll("textarea")].map(
(box) => box.id,
);
expect(hydrated).toEqual(served);
consoleError.mockRestore();
act(() => root.unmount());
container.remove();
});Both pass. Hydrate the counter version after an earlier server render and React logs the attribute mismatch the second test forbids.
Server rendering, and more than one app on a page
useId works in Server Components that are not async, and with server rendering it needs an identical tree on the server and the client. Render a branch only in the browser, behind a check for window, and the ids below it shift. When a page holds two React roots, such as the billing app and its pay-now widget on a customer’s site, give each root an identifierPrefix in createRoot or hydrateRoot, the same one the server rendered with, so their ids can’t collide.
React 18, 19 and the Compiler
useId arrived in React 18 and works the same way in 19. Only the format moved, which is one more reason not to depend on it. The React Compiler changes nothing here: an id is the same on every render of a mounted component, so there is nothing to memoise.
When a field needs no generated id
A label that wraps its control needs no id at all, and for a checkbox or a radio with a short name that is the simplest markup. The wrapping only names the control, though. A hint or an error still has to be pointed at with aria-describedby, so a field with either is back to needing an id.
And an element that is genuinely one of a kind on every page, such as the main landmark or a skip link’s target, can keep a written id. The moment it lives in a component that could render twice, give it useId.
How Turtlekit’s fields name their parts
Turtlekit’s TextArea, TextField and Field create their ids themselves. Give each a label and a hint, and the wiring follows:
// The invoice's note and the reminder's note with Turtlekit: TextArea
// makes its own ids, so two notes on one page never share one.
import { Card, Stack, TextArea } from "turtlekit";
export function InvoiceNotes() {
return (
<Stack gap="group">
<TextArea
name="note"
label="Note to customer"
hint="Printed above the amount due"
/>
<Card>
<Stack as="aside" gap="stack" aria-label="Send a reminder">
<TextArea
name="reminder-note"
label="Note to customer"
hint="Sent with the reminder email"
/>
</Stack>
</Card>
</Stack>
);
}The two notes carry the same label and never share an id, so each label focuses its own box and each hint describes its own note. Tabs ties each tab to its panel the same way, and every one of these ids matches between the server’s HTML and the hydrated page.
Built on this pattern in Turtlekit: Field.
When the ids don’t match
React warns that attributes didn’t match after hydration
Something renders differently on the server and in the browser above the field: a check for window, a date, a random number, or a counter. Render the same tree on both sides, and move browser-only parts into an effect that runs after hydration.
Two apps on one page produce the same ids
Each root builds its ids from its own tree, so two roots produce the same ones. Give them different identifierPrefix values, and pass the same prefix to the server render and to hydrateRoot.
Pitfall
Calling useId inside a loop to name each row breaks the rules of hooks. Move the row into its own component and call the hook there, or derive the row’s ids from one id and the row’s key from its data.
Ids belong to places, not to components
The note field’s first id was correct for the one place it was written for, which is why it looked correct. A component doesn’t know how many times it will appear, or whether it is rendering on a server or in a browser, so it can’t choose its own ids. React can, because it knows where the field sits in the tree. Let it choose, derive the parts from that one id, and the reminder’s label finds the reminder’s box on every page, however many notes it holds.