ReactのuseIdで、フォームのラベルと入力欄を結びつける方法

​​React​TypeScript,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 を使っています。

id を決め打ちにすると、2つ目のラベルが1つ目の入力欄を指す

<label> の for 属性(React では htmlFor)に入力欄の id を書くと、ラベルと入力欄が結びつきます。MDN の label 要素の説明によると、ラベルをクリックすると結びついた入力欄にフォーカスが移ります。スクリーンリーダーは、入力欄にフォーカスがあるときにラベルを読み上げます。

検証には、メールアドレスの入力欄と説明文をまとめた部品を使いました。入力欄の id は email、説明文の id は email-hint に決め打ちにしてあります。

tsx
// 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つ並べます(検証用のファイルから、このフォームの部分だけを抜粋)。

tsx
export function HardcodedForm() {
  return (
    <form>
      <HardcodedEmailField label="メールアドレス" />
      <HardcodedEmailField label="予備のメールアドレス" />
    </form>
  );
}

jsdom で描画し、それぞれのラベルがどの入力欄につながっているか(label.control)を調べた結果です。

text
--- 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 のアクセシビリティツリーから取り出した入力欄の名前・説明です。

text
--- hardcoded ---
「予備のメールアドレス」をクリック → フォーカスした入力欄: 1つ目
入力欄 1: 名前=「メールアドレス 予備のメールアドレス」 説明=「確認のメールが届きます」
入力欄 2: 名前=「」 説明=「確認のメールが届きます」

1つ目の入力欄の名前には、2つのラベルの文がつながって入りました。MDN には、同じ入力欄を指すラベルが複数あると、入力欄はそのすべてをラベルとして持つと書かれています。一方、2つ目の入力欄には名前がありません。支援技術でこの欄に移っても、何を入れる欄なのかが伝わらないことになります(スクリーンリーダーでの読み上げそのものは試していません)。

useId で部品ごとに id を作る

公式リファレンスの、アクセシビリティ用の属性に id を作る使い方でも、部品は1ページに何度も描かれることがあるので、id の決め打ちはよくないとされています。useId は、呼び出しごとに違う文字列を返すフックです。部品の関数の中の、ループや条件分岐の外(トップレベル)で呼び、返ってきた値を htmlFor・id・aria-describedby に渡します。

tsx
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つ並べた結果です。

text
--- 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つ目の入力欄にフォーカスが移り、それぞれの入力欄に自分のラベルの名前が付きました。

text
--- useId ---
「予備のメールアドレス」をクリック → フォーカスした入力欄: 2つ目
入力欄 1: 名前=「メールアドレス」 説明=「確認のメールが届きます」
入力欄 2: 名前=「予備のメールアドレス」 説明=「確認のメールが届きます」

2つのフォームを1つのページに縦に並べ、それぞれで「予備のメールアドレス」のラベルをクリックした様子です。

上の決め打ちのフォームでは、予備のメールアドレスのラベルをクリックすると1つ目の入力欄に枠が付く。下の useId のフォームでは、同じラベルのクリックで2つ目の入力欄に枠が付く
「予備のメールアドレス」のラベルをクリックしたときに、フォーカスが移る入力欄(上が id の決め打ち、下が useId)

なお、<label> の中に <input> を入れる書き方なら、for と id がなくてもラベルと入力欄は結びつきます。ただ MDN は、この書き方に対応していない支援技術もあるとして、for と id で結びつける書き方を勧めています。MDN の aria-describedby の説明にあるとおり、aria-describedby は説明する要素を id で指す属性です。説明文を結びつけるには、どちらの書き方でも id が要ります。

入力欄が複数ある部品では、useId を1回だけ呼ぶ

姓と名のように、1つの部品の中に入力欄がいくつかあるときも、useId を入力欄の数だけ呼ぶ必要はありません。公式リファレンスにある、関係する複数の要素に id を付ける書き方と同じく、1回呼んだ値を共通の頭の部分にして、後ろに入力欄ごとの文字を足します。

tsx
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>
  );
}
text
--- 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ずつ増やす書き方でもできそうに見えます。

tsx
// モジュールの変数を数えて 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 を比べた結果です。

text
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 が作られます。開発用のビルドでは、コンソールに次のエラーが出ました(抜粋)。

text
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つなら渡す必要はありません。この記事では、identifierPrefix は試していません。

​Posted by 86