ReactのuseIdで、フォームのラベルと入力欄を結びつける方法
入力欄を部品(コンポーネント)にして id="email" のように id を決め打ちにすると、同じ部品を1ページに2つ置いたときに id が重なります。すると、2つ目のラベルをクリックしても1つ目の入力欄にフォーカスが移り、支援技術に伝わる入力欄の名前もずれます。useId で部品ごとに id を作れば、ラベルと入力欄が1対1で正しく結びつきます。
筆者は検証環境で、id を決め打ちにした部品と useId を使った部品を2つずつ並べ、jsdom と Chromium で比べました。サーバーで HTML を作る場合に、数を数えて id を作る書き方と何が違うかもあわせて確かめています。
検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、jsdom 29.1.1 です。.tsx を tsc で型チェックしてから esbuild で変換し、jsdom と Chromium 141(Playwright 1.56.1 で操作)で動かしました。GIF への変換には ffmpeg 6.1.1 を使っています。1ページに複数の React アプリを置くときに使う identifierPrefix(アプリごとに id の頭の部分を変える設定)は試していません。
id を決め打ちにすると、2つ目のラベルが1つ目の入力欄を指す
<label> の for 属性(React では htmlFor)に入力欄の id を書くと、ラベルと入力欄が結びつきます。MDN の label 要素の説明によると、ラベルをクリックすると結びついた入力欄にフォーカスが移ります。スクリーンリーダーは、入力欄にフォーカスがあるときにラベルを読み上げます。
検証には、メールアドレスの入力欄と説明文をまとめた部品を使いました。入力欄の id は email、説明文の id は email-hint に決め打ちにしてあります。
// id を文字列で決め打ちにした入力欄(同じページに2つ置くと id が重なる)
export function HardcodedEmailField({ label }: { label: string }) {
return (
<div className="field">
<label htmlFor="email">{label}</label>
<input id="email" type="email" aria-describedby="email-hint" />
<p id="email-hint">確認のメールが届きます</p>
</div>
);
}
この部品を、「メールアドレス」と「予備のメールアドレス」の2つ並べます(検証用のファイルから、このフォームの部分だけを抜粋)。
export function HardcodedForm() {
return (
<form>
<HardcodedEmailField label="メールアドレス" />
<HardcodedEmailField label="予備のメールアドレス" />
</form>
);
}
jsdom で描画し、それぞれのラベルがどの入力欄につながっているか(label.control)を調べた結果です。
--- id を決め打ち(HardcodedForm) ---
label 1「メールアドレス」 for=email → つながる入力欄: 1つ目
label 2「予備のメールアドレス」 for=email → つながる入力欄: 1つ目
input 1 id=email 同じ id の要素の数=2 説明文=同じ部品の中
input 2 id=email 同じ id の要素の数=2 説明文=ほかの部品の中
2つ目のラベルも、1つ目の入力欄につながっています。MDN の同じ説明にあるとおり、for の値と同じ id の要素が複数あっても、ラベルが結びつくのは文書の中で最初の要素だけです。2つ目の入力欄の aria-describedby も、1つ目の部品の中にある説明文を指していました。
Chromium でも同じことが起きます。次は、「予備のメールアドレス」のラベルをクリックしてフォーカスが移った入力欄と、Chromium のアクセシビリティツリーから取り出した入力欄の名前・説明です。
--- hardcoded ---
「予備のメールアドレス」をクリック → フォーカスした入力欄: 1つ目
入力欄 1: 名前=「メールアドレス 予備のメールアドレス」 説明=「確認のメールが届きます」
入力欄 2: 名前=「」 説明=「確認のメールが届きます」
1つ目の入力欄の名前には、2つのラベルの文がつながって入りました。MDN には、同じ入力欄を指すラベルが複数あると、入力欄はそのすべてをラベルとして持つと書かれています。一方、2つ目の入力欄には名前がありません。支援技術でこの欄に移っても、何を入れる欄なのかが伝わらないことになります(スクリーンリーダーでの読み上げそのものは試していません)。
useId で部品ごとに id を作る
公式リファレンスの、アクセシビリティ用の属性に id を作る使い方でも、部品は1ページに何度も描かれることがあるので、id の決め打ちはよくないとされています。useId は、呼び出しごとに違う文字列を返すフックです。部品の関数の中の、ループや条件分岐の外(トップレベル)で呼び、返ってきた値を htmlFor・id・aria-describedby に渡します。
import { useId } from 'react';
// useId で作った id を、label・input・説明文で使い回す
export function EmailField({ label }: { label: string }) {
const id = useId();
const hintId = `${id}-hint`;
return (
<div className="field">
<label htmlFor={id}>{label}</label>
<input id={id} type="email" aria-describedby={hintId} />
<p id={hintId}>確認のメールが届きます</p>
</div>
);
}
説明文の id は、useId の値の後ろに -hint を足して作りました。この部品を、先ほどと同じように2つ並べた結果です。
--- useId(UseIdForm) ---
label 1「メールアドレス」 for=_r_0_ → つながる入力欄: 1つ目
label 2「予備のメールアドレス」 for=_r_1_ → つながる入力欄: 2つ目
input 1 id=_r_0_ 同じ id の要素の数=1 説明文=同じ部品の中
input 2 id=_r_1_ 同じ id の要素の数=1 説明文=同じ部品の中
Chromium でも、「予備のメールアドレス」のラベルをクリックすると、2つ目の入力欄にフォーカスが移り、それぞれの入力欄に自分のラベルの名前が付きました。
--- useId ---
「予備のメールアドレス」をクリック → フォーカスした入力欄: 2つ目
入力欄 1: 名前=「メールアドレス」 説明=「確認のメールが届きます」
入力欄 2: 名前=「予備のメールアドレス」 説明=「確認のメールが届きます」
2つのフォームを1つのページに縦に並べ、それぞれで「予備のメールアドレス」のラベルをクリックした様子です。

なお、<label> の中に <input> を入れる書き方なら、for と id がなくてもラベルと入力欄は結びつきます。ただ MDN は、この書き方に対応していない支援技術もあるとして、for と id で結びつける書き方を勧めています。MDN の aria-describedby の説明にあるとおり、aria-describedby は説明する要素を id で指す属性です。説明文を結びつけるには、どちらの書き方でも id が要ります。
入力欄が複数ある部品では、useId を1回だけ呼ぶ
姓と名のように、1つの部品の中に入力欄がいくつかあるときも、useId を入力欄の数だけ呼ぶ必要はありません。公式リファレンスにある、関係する複数の要素に id を付ける書き方と同じく、1回呼んだ値を共通の頭の部分にして、後ろに入力欄ごとの文字を足します。
import { useId } from 'react';
// useId は1回だけ呼び、後ろに文字を足して入力欄ごとの id にする
export function NameFields() {
const id = useId();
return (
<fieldset>
<legend>お名前</legend>
<label htmlFor={`${id}-family`}>姓</label>
<input id={`${id}-family`} />
<label htmlFor={`${id}-given`}>名</label>
<input id={`${id}-given`} />
</fieldset>
);
}
--- useId を1回だけ呼ぶ(NameFields) ---
label 1「姓」 for=_r_2_-family → つながる入力欄: 1つ目
label 2「名」 for=_r_2_-given → つながる入力欄: 2つ目
input 1 id=_r_2_-family 同じ id の要素の数=1
input 2 id=_r_2_-given 同じ id の要素の数=1
数を数えて id を作ると、サーバーとブラウザで食い違う
部品ごとに違う id を作るだけなら、モジュールの変数を1ずつ増やす書き方でもできそうに見えます。
// モジュールの変数を数えて id を作る(useId を使わない書き方)
let nextId = 0;
export function CounterEmailField({ label }: { label: string }) {
const id = `email-${nextId++}`;
return (
<div className="field">
<label htmlFor={id}>{label}</label>
<input id={id} type="email" />
</div>
);
}
1回描画するだけなら、下の結果の1回目のように email-0 と email-1 になり、id は重なりません。困るのは、Node.js のサーバーのように、同じプロセスで何度もリクエストを受けて HTML を作る場合です。変数が増え続けるからです。ここでは renderToString を2回呼んで、その状態を再現しました。この部品を2つ並べたフォームと useId 版のフォームを、それぞれ2回ずつ描いて id を比べた結果です。
counter: 1回目の id = email-0, email-1 / 2回目の id = email-2, email-3
useId: 1回目の id = _R_1_, _R_2_ / 2回目の id = _R_1_, _R_2_
2回目の HTML を Chromium で開き、hydrateRoot でハイドレーション(サーバーの HTML にブラウザ側の React をつなぐ処理)をしました。ブラウザ側の変数は0から数え始めるので、ブラウザでは email-0 と email-1 が作られます。開発用のビルドでは、コンソールに次のエラーが出ました(抜粋)。
A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. This won't be patched up. This can happen if a SSR-ed Client Component used:
<form>
<CounterEmailField label="メールアドレス">
<label
+ htmlFor="email-0"
- htmlFor="email-2"
+ がブラウザで作った値、- がサーバーの HTML にあった値です。メッセージのとおり、React はこの食い違いを直しません。ハイドレーションの後も、DOM の id は email-2, email-3 のままでした。useId 版はエラーが0件で、サーバーで2回描いても同じ id になりました。
公式リファレンスの、数を数える書き方より useId がよい理由によると、useId は呼んだ部品の「親からの道筋」をもとに id を作ります。そのため、サーバーとブラウザで部品の木が同じなら、描画の順番が違っても同じ id になります。逆に言うと、サーバーとブラウザで描く木が違うと、useId の id も食い違います。
ラベルや説明文のための id は、部品の中で useId を1回呼んで作ります。数を数える変数で作ると、サーバーで HTML を作るときに食い違います。
useId を使うときに気をつけること
id の文字列の形に頼らない
この検証では、ブラウザだけで描いたときは _r_0_、サーバーで描いたときは _R_1_ という形の id になりました。React の変更履歴によると、id の形は 19.1.0 で :r123: から «r123» に、19.2.0 でアンダースコアを使う形(この検証の 19.3.1 では _r_0_)に変わっています。id の形を前提にしたコードは書かず、useId が返した値をそのまま属性に渡します。
リストの key には使わない
公式リファレンスの注意点には、リストの key を作るのに useId を使わないように、と書かれています。key は、表示するデータから作ります。同じ注意点には、useId はフックなので、ループや条件分岐の中では呼べないことも書かれています。
1ページに React のアプリを複数置くとき
別々の React アプリを同じページに置く場合は、createRoot や hydrateRoot に identifierPrefix を渡すと、アプリごとに id の頭の部分を変えられます(公式リファレンスの、すべての id に共通の頭の部分を付ける方法)。アプリが1つなら渡す必要はありません。





