Skip to content
Chapter 24Lesson 6

useId for stable IDs across the server boundary

React's useId hook, the tool for generating accessible form-wiring ids that stay identical across server rendering and browser 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. In HTML that association is made by a shared string: the label carries htmlFor="email" and the input carries the matching id="email". When the same string sits on both, the browser links them.

Here’s the snag. TextField renders its own <label> and <input>, so TextField is the one that has to supply that id, and it has no idea what to use. If you hardcode id="email", the component works exactly once. The moment a form has two TextFields, you have two id="email" on one page. Two elements sharing an id is invalid HTML, so the browser quietly picks one of them. Now both labels point at the same input, and the second field is left unlabelled. The component needs a unique id that it cannot possibly know in advance.

You’ve spent this chapter deciding where a value lives: useState, the four homes, useReducer, a ref. This last hook isn’t about holding a value at all. useId takes no arguments, returns one string, and exists to solve exactly the problem above: handing a component a unique id to wire two DOM nodes together. The API is small, so most of the lesson is about two other things. The first is why it has to be a hook rather than a one-liner. The second is the two jobs people hand it that it’s the wrong tool for. By the end you’ll wire a label, an input, and an error message together correctly under server rendering, and you’ll know when not to reach for it.

Before reaching for any API, it helps to strip the requirement down to what it really is. This id carries no meaning: nobody reads it, and nothing depends on its contents. It only has to be unique on the page, so that two fields don’t collide, and stable enough to put on both nodes, so that the label and input agree. That’s all it is: plumbing.

So you need to generate one. There are three obvious ways to do that, and each one fails in a way that’s worth watching, because useId is shaped around those exact failures.

function TextField({ label }: { label: string }) {
return (
<>
<label htmlFor="field">{label}</label>
<input id="field" />
</>
);
}

Works for one field, but collides the instant the component is reused. Two of these on a page means two id="field", so the label points at whichever input the browser kept, and the field is silently miswired.

The hardcoded literal is the obvious miss. It works for a single field but collides the moment the component is reused, which is the moment it becomes useful. A reusable component cannot hardcode an id.

So you make it unique per instance instead. Math.random() gives a different string every call, which solves the collision but introduces a worse bug. To see the bug, you need one fact about how a React page reaches the screen. Under server rendering , your components run twice. The server runs them once to produce the initial HTML the user sees immediately. Then the browser runs the same components again over that HTML to attach event handlers and wire up state, a step called hydration .

These two runs have to agree, and hydration checks exactly that: it compares the HTML the browser produces against what the server sent. 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", the attributes disagree, and React fires a hydration-mismatch warning. The field might still half-work, or it might wire to the wrong node. Either way you’ve shipped a bug that never showed up while you were clicking around in dev.

crypto.randomUUID() is the move an experienced developer reaches for next, and it’s worth naming precisely so you can set the instinct aside. It produces a genuinely unique id, so it looks like the correct tool, but it fails for the exact same reason Math.random() did. It runs on the server, runs again in the browser, and hands back a different uuid each time. That’s the same mismatch, and now you’ve pulled in a cryptographic generator just to label an input.

Step back and the three failures line up. Hardcoding isn’t unique. Both generators are unique but not the same on both sides of the server boundary. What you need is something unique that the server and browser will independently compute to the same value. They share exactly one thing that’s guaranteed identical on both runs: the shape of the React tree itself, the same components nested the same way in the same order. An id derived from where a component sits in that tree, rather than from a random roll, comes out identical on both runs for free. That is precisely what useId does.

useId: one string, identical on both sides

Section titled “useId: one string, identical on both sides”

Here’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 TextField instance on the page gets its own id, so two of them never collide.
  • Stable. The same instance gets the same string on every re-render, so it won’t churn underneath you the way a fresh Math.random() would.
  • Identical on server and client. This is the property the whole lesson turns on. The server-rendered HTML and the browser’s hydration produce the same id, so the two never disagree.

The string it returns is opaque: a short token like «r1», format and all chosen by React. You never read it, parse it, slice a suffix off it for meaning, or write a CSS selector against it. It is a handle you pass to id, htmlFor, and the aria-* attributes, and nothing else.

So why is this a hook at all, and not a plain makeId() function? Because a plain function can’t know where in the tree it was called, and a hook can. Recall from the render model that React calls your hooks in the same order on every render. That fixed call order is exactly how React keeps each useState matched to its own value across renders, and useId rides the same machinery: it derives the id from the component’s position in the render tree. The server and the browser walk that tree in the same order, so both arrive at the same position, and therefore the same id. No coordination, no shared random seed, and no value passed across the wire. The determinism falls out of the tree being the same on both sides.

useId()

derived from tree position

Server render <App>
<Header>
<TextField> id = «r1»
Browser render <App>
<Header>
<TextField> id = «r1»

Same tree position → same id → no mismatch

Math.random()

rolled fresh each run

Server render <App>
<Header>
<TextField> id = 0.81…
Browser render <App>
<Header>
<TextField> id = 0.45…

Different roll each run → mismatch

Both renders walk the same tree. useId reads the TextField's position, so it lands on the same id on the server and in the browser. Math.random ignores the tree and rolls a fresh number each run, so the two ids disagree and React reports a mismatch.

One consequence is worth keeping in mind: because the id is tied to tree position, it is not permanent across a component’s whole life. If a component unmounts and later remounts, for instance 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 anyway. It only surprises you if you’ve stashed the id somewhere expecting it to live forever, which is why you shouldn’t.

useId works the same way under server rendering and inside Client Components: the id is generated during the render, serialised into the HTML, and matched again on hydration. There is no special case to learn. You don’t generate ids one way for server-rendered output and another way in the browser. The same useId() call is correct everywhere a component renders.

Wiring a field: label, input, and error message

Section titled “Wiring a field: label, input, and error message”

This is the pattern you’ll actually copy into real code, so let’s build the whole field.

The core is what you’ve already seen: one useId() call, its string on the input’s id, and the same string on the label’s htmlFor. That single association is what turns “edit text, blank” into “Email, edit text” for a screen-reader user. It’s the difference between a form anyone can fill out and one that’s quietly unusable for a slice of your customers.

A real field needs more than one id, though. There’s the input itself, and there’s 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. You might want a hint line too. 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`;

Calling useId() a second time for the second id is perfectly legal too, and you’ll see it in library code, so don’t be thrown by it. One call plus suffixes reads better, though, because it says these ids belong to the same field. You can see the relationship at a glance instead of tracking two independent calls.

The error node only exists when there’s actually an error, so it’s a conditional render. Guard it with a real boolean, error != null && <p id={errorId}>…</p>, the same discipline this chapter has used throughout, so that an empty string or a 0 can never leak a stray node into the markup. Match the house standard for form fields while you’re here. Alongside aria-describedby, set aria-invalid={error != null} so that assistive tech announces the field as invalid, not merely that there’s some text nearby.

That leaves one genuine judgement call. aria-describedby points at an id, but when there’s no error, that node isn’t in the DOM, so the attribute references nothing. A dangling aria-describedby is harmless, since screen readers ignore a missing target, so you can leave it always set without any problem. The slightly cleaner move is to set it only when the error is present, so that the attribute never points at a missing node. Both are defensible. The snippet below sets it conditionally so that you’ve seen the tidy version.

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>
);
};

This is the core association. The matching string is the entire link between the two nodes, and it’s 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 and there’s no description.

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, the conditional in step 3 has already dropped the reference.

1 / 1

Notice what’s not in that component: a single hardcoded id. Every id is generated and passed by reference, and that’s the habit to build. 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.

Preview
    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 htmlFor on the label, 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 are both driven 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 rather than a fixed id string, which captures the whole point. There is no “right” id to type, only a right way to connect nodes: generate once, reference everywhere.

    The API is small, so the judgement is in knowing where it doesn’t belong. Two misuses are close to universal, and both are conceptual rather than stylistic. Getting them wrong changes how your code behaves, not just how it reads.

    Not for list keys. This is the one nearly everyone reaches for. Recall the key contract from the previous chapter: a list key is reconciliation identity, the string React uses to track which rendered item corresponds to which across renders, so that it can move and reuse DOM instead of rebuilding it. A key must come from the data, such as item.id, a slug, or an id stamped on the row when it was created. It also has to stay fixed for the life of that datum.

    useId can’t supply that, and not just as a matter of taste: it’s structurally impossible. useId produces an id for a component instance that already exists. A key has to identify an item before React has decided which instance to create or reuse, because keying is the step that chooses the instances. You’d be asking React for the id of a thing it hasn’t built yet, in order to decide whether to build it. Keys come from your data; useId comes from the tree React assembles after keying.

    So where do keys come from when the data has no natural id, like a to-do the user just typed with no server id yet? Mint a crypto.randomUUID() at 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 when 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: now it’s stable, it lives with the datum, and it survives 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, not unguessable, and not unique across pages or sessions: load the page twice and you may get the same string back. That makes it the wrong tool for anything that needs cryptographic uniqueness, such as CSRF tokens, session ids, or idempotency keys. Those come from a real generator on the server (crypto.randomUUID() and friends), and you’ll meet them properly in the server chapters.

    It’s equally wrong for ids a human relies on, like the #pricing anchor someone bookmarks and shares. Those have to be stable across deploys, readable, and hand-authored, and an opaque, tree-derived token like «r1» is none of those.

    Two more guardrails, both one-liners and both familiar:

    • Keep useId calls out of conditionals and loops. This is the rule for every hook, and it foreshadows the rules of hooks. The call order is what fixes the id to a tree position. If a useId call appears or disappears depending on a condition, its position can shift between the server and the browser, and you’re back to a mismatch.
    • Don’t style by these ids. They’re wiring tokens for id, htmlFor, and aria-*, not selector targets. Reaching into CSS to match on a useId value is a mistake whatever the format happens to be: style with classes, wire with useId.

    Sort the following into the tool that should produce each id. This is the distinction the back half of the lesson has been building toward.

    Sort each id into the tool that should produce it. Drag each item into the bucket it belongs to, then press Check.

    useId Wiring two DOM nodes together across SSR.
    crypto.randomUUID A real random id: minted once and stored, or generated server-side.
    Hardcoded / human-authored A stable, readable string you write by hand.
    Linking a <label> to its <input>
    Pointing aria-describedby at a field’s error text
    Two <TextField>s on the same page, each needing its own id
    The key for a list of to-dos the user just typed, with no server id yet
    A CSRF token for a form submission
    A session id
    The #pricing anchor users bookmark
    The id in a footer link’s href that jumps to a section on the page

    One last thing is worth knowing: you will rarely write the TextField above 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. When you later drop in a <Label> and an <Input> and they announce correctly, this is the machinery underneath. The course’s own form components, when you build them, reach for useId at every label-input pairing for the same reason.

    This matters the moment you wrap a third-party input in your own component. Your wrapper is where the useId call goes, and it passes the id down to the inner element as a prop. The id is generated once, at the boundary you own, and handed inward. It’s the same generate-once-reference-everywhere shape, one level up.