useId for stable form-wiring IDs
React's useId hook, for generating accessible form-wiring ids that stay identical across server rendering and hydration.
Say you’re building a TextField component: a <label> and an <input> together, the thing you’ll drop into every form in the app.
<TextField label="Email" />For a screen reader to announce that field as “Email, edit text” instead of “edit text, blank”, the label has to be associated with the input. HTML makes that association through a shared string: the label carries htmlFor="email" and the input carries the matching id="email". Same string on both, and the browser links them.
Here’s the snag. TextField renders its own <label> and <input>, so it’s the component that has to supply that id, and it has no idea what to use. Hardcode id="email" and it works exactly once: the moment a form holds two TextFields, the page has two id="email". Duplicate ids are invalid HTML, so the browser quietly picks one, both labels point at the same input, and the second field is left unlabelled. TextField needs a unique id it cannot know in advance.
That’s what useId is for. It takes no arguments and returns one string, handing a component a unique id to wire two DOM nodes together.
Why you can’t just make up an id
Section titled “Why you can’t just make up an id”The id carries no meaning: nobody reads it, and nothing depends on its contents. It only has to be unique on the page, so two fields don’t collide, and identical on both nodes, so the label and input agree. It’s plumbing.
So you generate one. There are three obvious ways, and the first two break in ways that explain what useId is for.
function TextField({ label }: { label: string }) { return ( <> <label htmlFor="field">{label}</label> <input id="field" /> </> );}Works for one field, but two on a page means two id="field". The label points at whichever input the browser kept, and the field is silently miswired.
function TextField({ label }: { label: string }) { const id = `field-${Math.random()}`; return ( <> <label htmlFor={id}>{label}</label> <input id={id} /> </> );}Unique, but a different value on the server than in the browser. The server stamps field-0.81…, the browser computes field-0.45…, and React warns about a mismatch.
function TextField({ label }: { label: string }) { const id = useId(); return ( <> <label htmlFor={id}>{label}</label> <input id={id} /> </> );}Unique, stable, and the same string on server and client. One call slots into the exact spot the two broken versions did. The next section covers it in full.
Hardcoding fails the moment the component is reused, which is the moment it becomes useful. A reusable component can’t hardcode an id.
So you make it unique per instance with Math.random(). That solves the collision but introduces a worse bug, and seeing it takes one fact about how a React page reaches the screen. Under server rendering , your components run twice: once on the server to produce the initial HTML, then again in the browser to attach event handlers and wire up state, a step called hydration .
Hydration compares the HTML the browser produces against what the server sent, and the two have to agree. Math.random() runs in both and rolls a different number each time, so the server stamps id="field-0.81", the browser computes id="field-0.45", and React fires a hydration-mismatch warning. The field might half-work or wire to the wrong node. Either way you’ve shipped a bug that never appeared while clicking around in dev.
crypto.randomUUID() looks like the fix, since it’s genuinely unique, but it breaks for the exact same reason: it runs on the server, runs again in the browser, and returns a different uuid each time. Same mismatch, plus a cryptographic generator pulled in just to label an input.
The pattern is clear. Hardcoding isn’t unique; both generators are unique but differ across the server boundary. You need a value that’s unique and that the server and browser compute to the same result independently. They share exactly one thing guaranteed identical on both runs: the shape of the React tree, the same components nested the same way in the same order. An id derived from where a component sits in that tree comes out identical on both runs for free. That’s what useId does.
useId: one string, identical on both sides
Section titled “useId: one string, identical on both sides”That’s the entire API.
const id = useId();
<input id={id} />No arguments. It returns one string, and three facts about that string carry everything:
- Unique. Each
TextFieldinstance gets its own id, so two on the page never collide. - Stable. The same instance keeps the same string across re-renders, instead of churning the way a fresh
Math.random()would. - Identical on server and client. The server-rendered HTML and the browser’s hydration produce the same id, so the two never disagree. This is the property the lesson turns on.
The string is opaque: a short token like «r1», format chosen by React. Pass it to id, htmlFor, and aria-*, and nothing else. Never parse it, slice it for meaning, or write a CSS selector against it.
So why a hook, and not a plain makeId()? A plain function can’t tell where it was called; a hook can. React calls your hooks in the same order on every render, the same fixed order that keeps each useState matched to its value, and useId derives the id from the component’s position in that render tree. The server and the browser walk the tree in the same order, reach the same position, and produce the same id. No shared seed, no value crossing the wire: the determinism falls out of the tree being the same on both sides.
useId()
derived from tree position
Same tree position → same id → no mismatch
Math.random()
rolled fresh each run
Different roll each run → mismatch
Because the id is tied to tree position, it is not permanent across a component’s life. If a component unmounts and remounts, say after a key reset of the kind from the last chapter, it can come back with a different id. That’s harmless for ARIA wiring, which re-reads the current attributes on every render. It only bites if you’ve stashed the id somewhere expecting it to live forever, so don’t.
The same useId() call works in server-rendered output and in Client Components: the id is generated during render, serialized into the HTML, and matched again on hydration. There’s no separate path for the browser.
Wiring a field: label, input, and error message
Section titled “Wiring a field: label, input, and error message”This is the pattern you’ll copy into real code, so let’s build the whole field.
The core is what you’ve seen: one useId() call, its string on the input’s id, and the same string on the label’s htmlFor. That single association turns “edit text, blank” into “Email, edit text” for a screen-reader user, the difference between a form anyone can fill out and one that’s quietly unusable for some of your customers.
A real field needs more than one id, though. There’s the input, and usually an error message that has to be announced as part of the field when validation fails. That message is wired with aria-describedby , which points at the id of the error element. So you need several related ids, and the question is how to mint them.
The convention is to call useId() once and derive the rest by suffixing the base:
const id = useId();const errorId = `${id}-error`;One call plus suffixes says these ids belong to the same field, so you see the relationship at a glance.
The error node only exists when there’s an error, so it’s a conditional render. Guard it with a real boolean, error != null && <p id={errorId}>…</p>, the discipline this chapter has used throughout, so an empty string or a 0 can’t leak a stray node into the markup. Match the house standard while you’re here: alongside aria-describedby, set aria-invalid={error != null} so assistive tech announces the field as invalid, not merely that there’s some text nearby.
That leaves one judgement call. When there’s no error, the error node isn’t in the DOM, so aria-describedby would reference nothing. A dangling reference is harmless, since screen readers ignore a missing target, so leaving it always set is fine. Setting it only when the error is present is slightly tidier, and that’s what the snippet below does.
type TextFieldProps = { label: string; error?: string;};
export const TextField = ({ label, error }: TextFieldProps) => { const id = useId(); const errorId = `${id}-error`;
return ( <div> <label htmlFor={id}>{label}</label> <input id={id} aria-invalid={error != null} aria-describedby={error != null ? errorId : undefined} /> {error != null && <p id={errorId}>{error}</p>} </div> );};One call gives two ids. The base goes on the input, and -error is suffixed for the message. Calling useId again would also work, but suffixing keeps it visibly one field.
type TextFieldProps = { label: string; error?: string;};
export const TextField = ({ label, error }: TextFieldProps) => { const id = useId(); const errorId = `${id}-error`;
return ( <div> <label htmlFor={id}>{label}</label> <input id={id} aria-invalid={error != null} aria-describedby={error != null ? errorId : undefined} /> {error != null && <p id={errorId}>{error}</p>} </div> );};The core association. The matching string is the entire link between the two nodes, the line the screen reader follows to read “Email” as the field’s name.
type TextFieldProps = { label: string; error?: string;};
export const TextField = ({ label, error }: TextFieldProps) => { const id = useId(); const errorId = `${id}-error`;
return ( <div> <label htmlFor={id}>{label}</label> <input id={id} aria-invalid={error != null} aria-describedby={error != null ? errorId : undefined} /> {error != null && <p id={errorId}>{error}</p>} </div> );};When an error exists, the field is announced as invalid and its description points at the error text. When there’s no error, undefined drops the attribute.
type TextFieldProps = { label: string; error?: string;};
export const TextField = ({ label, error }: TextFieldProps) => { const id = useId(); const errorId = `${id}-error`;
return ( <div> <label htmlFor={id}>{label}</label> <input id={id} aria-invalid={error != null} aria-describedby={error != null ? errorId : undefined} /> {error != null && <p id={errorId}>{error}</p>} </div> );};The error node carries the id that aria-describedby targets. The proper-boolean guard means an empty string never renders a stray paragraph, and when the error is absent, step 3 has already dropped the reference.
Notice what’s not in that component: a single hardcoded id. Every id is generated and passed by reference. You wire two nodes by handing them the same generated string, never by typing a literal into two places and hoping they stay in sync.
Now wire one yourself. The exercise below gives you a TextField whose label, input, and error message are all present but completely disconnected. Connect them so the field works.
Wire this field so it's usable with a screen reader. Call useId and derive an errorId from it. Give the label and input the same id so the label names the input. Then point the input's aria-describedby at the error paragraph's id, and set aria-invalid to reflect whether an error prop was passed.
Reference solution
import { useId } from 'react';
type TextFieldProps = { label: string; error?: string;};
const TextField = ({ label, error }: TextFieldProps) => { const id = useId(); const errorId = `${id}-error`;
return ( <div className="space-y-1"> <label htmlFor={id} className="block text-sm font-medium">{label}</label> <input id={id} aria-invalid={error != null} aria-describedby={error != null ? errorId : undefined} className="block w-full rounded border px-2 py-1" /> {error != null && <p id={errorId} className="text-sm text-red-600">{error}</p>} </div> );};
export const App = () => { return ( <div className="max-w-xs space-y-4 p-4"> <TextField label="Email" error="Enter a valid email address." /> <TextField label="Name" /> </div> );};One useId() call feeds both the input’s id and the label’s htmlFor, and that shared string is the entire label-to-input link. errorId is the same base with -error suffixed, so the relationship reads at a glance. aria-invalid and aria-describedby both run off error != null: when an error is present, the field announces as invalid and points at the error text; when it’s absent, undefined drops the description and the guarded <p> never renders. Because the id comes from each instance’s position in the tree, the two fields get different ids for free, where a hardcoded literal would collide and fail the distinct-ids check.
The tests grade the association, not a fixed id string. There’s no “right” id to type, only a right way to connect nodes: generate once, reference everywhere.
Two things useId is not for
Section titled “Two things useId is not for”The API is small, so the judgement is knowing where it doesn’t belong. Two misuses are nearly universal, and both change how your code behaves, not just how it reads.
Not for list keys. This is the one almost everyone reaches for. Recall the key contract from the previous chapter: a key is reconciliation identity, the string React uses to match each rendered item across renders so it can move and reuse DOM instead of rebuilding it. A key must come from the data, such as item.id or a slug, and stay fixed for the life of that datum.
useId can’t supply that, and the reason is structural, not stylistic. A key has to identify an item before React decides which instance to create or reuse, because keying is what chooses the instances. useId produces an id only after that, for an instance that already exists. Keys come from your data; useId comes from the tree React assembles once keying is done.
So where do keys come from when the data has no natural id, like a to-do the user just typed? Mint a crypto.randomUUID() the moment you create the item, and store it on the item. Note the contrast with the earlier section: crypto.randomUUID() was the wrong tool called during render for an attribute, because it rolls fresh on every render and across the server boundary. Called once at creation and saved onto the data, it’s exactly right: stable, living with the datum, surviving every render after.
Not for secrets or human-readable anchors. useId’s output is stable per position, which is the opposite of secret. It is not random and not unique across pages or sessions: load the page twice and you may get the same string back. That rules it out for anything needing cryptographic uniqueness, such as CSRF tokens, session ids, or idempotency keys, which come from a real generator on the server.
It’s equally wrong for ids a human relies on, like the #pricing anchor someone bookmarks and shares. Those must be stable across deploys, readable, and hand-authored, and an opaque token like «r1» is none of those.
Two more guardrails:
- Keep
useIdcalls out of conditionals and loops. The call order is what fixes each id to a tree position. If a call appears or disappears with a condition, its position can shift between server and browser, and you’re back to a mismatch. - Don’t style by these ids. They’re wiring tokens for
id,htmlFor, andaria-*, not selector targets. Style with classes, wire withuseId.
Sort the following into the tool that should produce each id.
Sort each id into the tool that should produce it. Drag each item into the bucket it belongs to, then press Check.
<label> to its <input>aria-describedby at a field’s error text<TextField>s on the same page, each needing its own id#pricing anchor users bookmarkid in a footer link’s href that jumps to a section on the pageLibraries call useId for you
Section titled “Libraries call useId for you”You will rarely write TextField from scratch. The component libraries you’ll reach for, shadcn/ui and the Radix primitives under it, call useId internally to wire ARIA on every input, label, and dialog they ship.
You write the call yourself only when wrapping a third-party input in your own component. The wrapper owns the useId call and passes the id down to the inner element as a prop.
External resources
Section titled “External resources”The full API, including React's own pitfall note that it's for accessibility attributes, not list keys.
The attribute that points a field at its error text — exactly the wiring built in this lesson.
The authoritative tutorial on associating a label with an input via matching for / id — the why behind the whole lesson.
Google's broader guide to making forms usable with assistive tech, putting label and aria wiring in context.