Skip to content
Get the beta

Search the docs

Start with the beta
Installation
Build your first page
Thinking in Turtlekit
Apps, websites and embeds
Components or native elements
Colour
Typography
Layout
Spacing, icons and shape
Components
Root
Layout
Typography
Code block
Button
Icon button
Button group
Call to action
Toolbar
Field
Text field
Text area
Select
Combobox
Date input
Save bar
Newsletter signup
File upload
Calendar
One-time code
Checkbox
Radio group
Switch
Segmented control
Toggle
Slider
Badge
Card
Data list
Item
Icon tile
Num
Time
Metric
Accordion
FAQ
Avatar
Media
Scroll area
Resizable
Carousel
Image slider
Logo cloud
Testimonials
Table
Data table
Chart
Site header
Site footer
Tabs
Page header
Breadcrumb
Pagination
Sidebar
Navigation menu
Callout
Error summary
Toast
Empty state
Skeleton
Progress
Spinner
Tooltip
Dialog
Popover
Hover card
Dropdown menu
Context menu
Menubar
Command
Elements area
Visually hidden
Error boundary
Design rules
Staying within the kit
Layout
Icons
Images and media
Typography
Readability
Colour
Composition
Accessibility
Elements
Motion
Interaction
AI pitfalls
Pricing
React design patterns
Single responsibility in React components
Presentational and container components in React
Children, function children and render props in React
The variant pattern in React
Compound components in React
Higher-order components in React
State colocation in React
Lifting state up in React
Deriving state during render
One status instead of many booleans
Adjusting state when a prop changes
Reading context with use() in React 19
Context selectors in React
Custom hooks and hook composition in React
One effect per concern in React
The latest ref pattern in React
Effect Events with useEffectEvent in React
Detecting the first render in React
Exposing methods with useImperativeHandle in React
Debouncing input in React
useDeferredValue in React
Loading components on demand in React
Virtualising long lists in React
Guard clauses and early returns in React
Skeleton placeholders in React
Rendering partial data in React
Empty states in React
Error boundaries in React
Reading async data with use() and Suspense in React
useTransition and Actions in React
Optimistic UI updates with useOptimistic
useActionState and form Actions in React
useSyncExternalStore in React
Ref as a prop and ref cleanup in React 19
Accessible ids with useId in React
Portals in React
Roving focus and keyboard navigation in React
Controlled and uncontrolled components in React
The state reducer and prop getters in React
Headless components and the provider pattern in React
The polymorphic as prop in React

Appearance

These change every page and preview on this site.

Accessible ids with useId in React

Turtlekit Editorial Team

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.

InvoicePageformasideNoteFieldNoteFieldid from its placeid from its place
useId names a call by its place in the tree, not by when it rendered. The server and the browser build the same tree, so each NoteField gets the same id in both, and the two NoteFields never get the same one.

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.

Compound components in React

When the parts that share the ids are separate components.

Ref as a prop and ref cleanup in React 19

When you need the element itself, not a way to point at it.

Roving focus and keyboard navigation in React

When the ids tie tabs to their panels in a keyboard widget.

Custom hooks and hook composition in React

When the wiring itself should be one hook that many fields call.