1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React Hooks を Hackしよう!【Part16: useIdをふかぼってみよう】

Last updated at Posted at 2025-12-17

#React でフォームを作成するとき、「input と label を紐付けるための ID をどう管理しよう?」と悩んだことはありませんか?useId は、そんな悩みを解決するためのフックです。サーバーサイドレンダリング(SSR)でも安全に使える、一意の ID を生成できます。

1. useId とは

1.1 基本概念

useId は、アクセシビリティ属性に渡すことができる一意の ID を生成するための React フックです。

import { useId } from 'react';

function PasswordField() {
  const passwordHintId = useId();
  return (
    <>
      <label>
        Password:
        <input
          type="password"
          aria-describedby={passwordHintId}
        />
      </label>
      <p id={passwordHintId}>
        The password should contain at least 18 characters
      </p>
    </>
  );
}

1.2 なぜ useId が必要か?

❌ ハードコードした ID の問題

// 問題: 同じコンポーネントを複数回使うと ID が重複する
function PasswordField() {
  return (
    <>
      <input type="password" aria-describedby="password-hint" />
      <p id="password-hint">パスワードのヒント</p>
    </>
  );
}

// ❌ 2回使うと id="password-hint" が重複!
<PasswordField />
<PasswordField />

❌ インクリメントカウンタの問題

let nextId = 0;

function PasswordField() {
  const id = `password-${nextId++}`;  // ❌ SSR で問題発生!
  return <input aria-describedby={id} />;
}

問題点:

  • サーバーとクライアントで ID が異なる(ハイドレーションエラー)
  • コンポーネントのレンダー順序に依存する
  • React の並行レンダリングで予測不能な動作

✅ useId の解決策

function PasswordField() {
  const id = useId();  // ✅ 常に一意で SSR にも対応
  return <input aria-describedby={id} />;
}

1.3 useId の API

const id = useId()
項目 説明
引数 なし
返り値 一意の ID 文字列(例: ":r0:", ":r1:", "_my-app-r_1_"

1.4 他のアプローチとの比較

特徴 useId インクリメントカウンタ UUID/nanoid
SSR 対応 ✅ 完全対応 ❌ ハイドレーション不一致 ❌ 毎回異なる値
一意性 ✅ コンポーネントツリーで保証 ⚠️ レンダー順序依存 ✅ ランダム
パフォーマンス ✅ 非常に軽量 ✅ 軽量 ⚠️ 乱数生成コスト
決定論的 ✅ ツリー構造から決定 ❌ 順序依存 ❌ ランダム

重要な注意点

  • useId はフックなので、コンポーネントのトップレベルまたはカスタムフック内でのみ呼び出せます
  • リストの key には使用しないでください。key はデータから生成する必要があります
  • 現在、async Server Components では使用できません

2. useId の内部構造を徹底解剖

useId の内部実装は、SSR とハイドレーションを完璧にサポートするために、非常に巧妙な設計がなされています。

2.0 全体像: useId の動作原理

🎣 useId(フック呼び出し)
   ↓
🔀 環境判定(ハイドレーション中 or クライアントのみ)
   ↓
   ├── 🌐 ハイドレーション中:
   │    ↓
   │    🌳 TreeContext から treeId を取得
   │    ↓
   │    📝 ID = "_" + prefix + "R_" + treeId + "_"
   │
   └── 💻 クライアントのみ:
        ↓
        🔢 globalClientIdCounter をインクリメント
        ↓
        📝 ID = "_" + prefix + "r_" + counter.toString(32) + "_"
        
   ↓
💾 Hook の memoizedState に保存
   ↓
🔄 以降の更新では保存した ID を返す

重要なポイント: サーバーとクライアントで同じ ID を生成するために、「コンポーネントツリー内の位置」を使った巧妙なアルゴリズムが採用されています!

2.1 エントリポイント: packages/react/src/ReactHooks.js

引用元: packages/react/src/ReactHooks.js (L183-L186)

// packages/react/src/ReactHooks.js

export function useId(): string {
  const dispatcher = resolveDispatcher();
  return dispatcher.useId();
}

他のフックと同様に、resolveDispatcher() で取得したディスパッチャの useId メソッドを呼び出します。

2.2 マウント時の処理: mountId

引用元: packages/react-reconciler/src/ReactFiberHooks.js (L3450-L3487)

// packages/react-reconciler/src/ReactFiberHooks.js

function mountId(): string {
  const hook = mountWorkInProgressHook();

  const root = ((getWorkInProgressRoot(): any): FiberRoot);
  // TODO: In Fizz, id generation is specific to each server config. Maybe we
  // should do this in Fiber, too? Deferring this decision for now because
  // there's no other place to store the prefix except for an internal field on
  // the public createRoot object, which the fiber tree does not currently have
  // a reference to.
  const identifierPrefix = root.identifierPrefix;

  let id;
  if (getIsHydrating()) {
    const treeId = getTreeId();

    // Use a captial R prefix for server-generated ids.
    id = '_' + identifierPrefix + 'R_' + treeId;

    // Unless this is the first id at this level, append a number at the end
    // that represents the position of this useId hook among all the useId
    // hooks for this fiber.
    const localId = localIdCounter++;
    if (localId > 0) {
      id += 'H' + localId.toString(32);
    }

    id += '_';
  } else {
    // Use a lowercase r prefix for client-generated ids.
    const globalClientId = globalClientIdCounter++;
    id = '_' + identifierPrefix + 'r_' + globalClientId.toString(32) + '_';
  }

  hook.memoizedState = id;
  return id;
}

処理の解説

  1. フックノードの作成: mountWorkInProgressHook() で新しいフックノードを作成
  2. プレフィックスの取得: createRoot に渡された identifierPrefix を取得
  3. 環境判定: getIsHydrating() でハイドレーション中かどうかを判定
  4. ID の生成:
    • ハイドレーション中: _<prefix>R_<treeId>_ 形式(大文字 R)
    • クライアントのみ: _<prefix>r_<counter>_ 形式(小文字 r)
  5. memoizedState に保存: 生成した ID をフックに保存

2.3 更新時の処理: updateId

引用元: packages/react-reconciler/src/ReactFiberHooks.js (L3489-L3493)

// packages/react-reconciler/src/ReactFiberHooks.js

function updateId(): string {
  const hook = updateWorkInProgressHook();
  const id: string = hook.memoizedState;
  return id;
}

更新時は、マウント時に保存した ID をそのまま返すだけです。ID は一度生成されると変更されません

2.4 TreeContext: サーバー・クライアント間で ID を一致させる仕組み

useId の最も重要な機能は、SSR で生成した ID とクライアントでハイドレーション時に生成する ID が一致することです。これを実現するのが TreeContext です。

引用元: packages/react-reconciler/src/ReactFiberTreeContext.js (L9-L58)

// packages/react-reconciler/src/ReactFiberTreeContext.js

// Ids are base 32 strings whose binary representation corresponds to the
// position of a node in a tree.

// Every time the tree forks into multiple children, we add additional bits to
// the left of the sequence that represent the position of the child within the
// current level of children.
//
//      00101       00010001011010101
//      ╰─┬─╯       ╰───────┬───────╯
//   Fork 5 of 20       Parent id
//
// The leading 0s are important. In the above example, you only need 3 bits to
// represent slot 5. However, you need 5 bits to represent all the forks at
// the current level, so we must account for the empty bits at the end.
//
// For this same reason, slots are 1-indexed instead of 0-indexed. Otherwise,
// the zeroth id at a level would be indistinguishable from its parent.

TreeContext の仕組み

ツリー構造:
       App
      /   \
   Form   Form
    /
  Input

ID の生成(ビット表現):
- App:        1 (ルート)
- Form(1):    01 1  → "11" (2進数) → "3" (base32)
- Form(2):    10 1  → "101" (2進数) → "5" (base32)  
- Input:      01 01 1 → "1011" (2進数)

2.5 getTreeId 関数の実装

引用元: packages/react-reconciler/src/ReactFiberTreeContext.js (L99-L103)

// packages/react-reconciler/src/ReactFiberTreeContext.js

export function getTreeId(): string {
  const overflow = treeContextOverflow;
  const idWithLeadingBit = treeContextId;
  const id = idWithLeadingBit & ~getLeadingBit(idWithLeadingBit);
  return id.toString(32) + overflow;
}

ツリー内の位置をビット演算で計算し、base32 文字列に変換します。

2.6 localIdCounter: 同一コンポーネント内の複数の useId

引用元: packages/react-reconciler/src/ReactFiberHooks.js (L283)

// Counts the number of useId hooks in this component.
let localIdCounter: number = 0;

同じコンポーネント内で複数の useId を呼び出した場合、localIdCounter で区別します:

function Form() {
  const id1 = useId();  // "_r_0_"
  const id2 = useId();  // "_r_0_H1_" (H1 が追加される)
  const id3 = useId();  // "_r_0_H2_"
  // ...
}

2.7 サーバーサイドでの ID 生成

引用元: packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js (L1091-L1107)

// packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js

export function makeId(
  resumableState: ResumableState,
  treeId: string,
  localId: number,
): string {
  const idPrefix = resumableState.idPrefix;

  let id = '_' + idPrefix + 'R_' + treeId;

  // Unless this is the first id at this level, append a number at the end
  // that represents the position of this useId hook among all the useId
  // hooks for this fiber.
  if (localId > 0) {
    id += 'H' + localId.toString(32);
  }

  return id + '_';
}

サーバーサイド(Fizz)でも同じ形式で ID を生成することで、ハイドレーション時に一致することが保証されます。

2.8 内部構造のまとめ図

3. 基本的な使い方

3.1 アクセシビリティ属性への使用

import { useId } from 'react';

function PasswordField() {
  const passwordHintId = useId();
  
  return (
    <>
      <label>
        Password:
        <input
          type="password"
          aria-describedby={passwordHintId}
        />
      </label>
      <p id={passwordHintId}>
        パスワードは18文字以上で入力してください
      </p>
    </>
  );
}

// 複数回使用しても安全
export default function App() {
  return (
    <>
      <h2>パスワードを選択</h2>
      <PasswordField />
      <h2>パスワードを確認</h2>
      <PasswordField />
    </>
  );
}

3.2 複数の関連要素への使用

複数の要素に ID が必要な場合、useId の結果をプレフィックスとして使用します:

import { useId } from 'react';

function Form() {
  const id = useId();
  
  return (
    <form>
      <label htmlFor={id + '-firstName'}>First Name:</label>
      <input id={id + '-firstName'} type="text" />
      <hr />
      <label htmlFor={id + '-lastName'}>Last Name:</label>
      <input id={id + '-lastName'} type="text" />
      <hr />
      <label htmlFor={id + '-email'}>Email:</label>
      <input id={id + '-email'} type="email" />
    </form>
  );
}

3.3 カスタムプレフィックスの設定

複数の React アプリを同一ページで使用する場合:

// index.js
import { createRoot } from 'react-dom/client';

// アプリ1
const root1 = createRoot(document.getElementById('root1'), {
  identifierPrefix: 'app1-'
});
root1.render(<App />);

// アプリ2
const root2 = createRoot(document.getElementById('root2'), {
  identifierPrefix: 'app2-'
});
root2.render(<App />);

生成される ID:

  • アプリ1: _app1-r_0_, _app1-r_1_, ...
  • アプリ2: _app2-r_0_, _app2-r_1_, ...

3.4 SSR での使用

サーバーとクライアントで同じプレフィックスを設定することが重要です:

// Server
import { renderToPipeableStream } from 'react-dom/server';

const { pipe } = renderToPipeableStream(
  <App />,
  { identifierPrefix: 'my-app-' }
);

// Client
import { hydrateRoot } from 'react-dom/client';

hydrateRoot(
  document.getElementById('root'),
  <App />,
  { identifierPrefix: 'my-app-' }  // サーバーと同じ値
);

4. ユースケース

4.1 フォームのラベルと入力フィールドの紐付け

import { useId } from 'react';

interface FormFieldProps {
  label: string;
  type?: string;
  required?: boolean;
}

function FormField({ label, type = 'text', required = false }: FormFieldProps) {
  const inputId = useId();
  const errorId = useId();
  
  return (
    <div className="form-field">
      <label htmlFor={inputId}>
        {label}
        {required && <span aria-hidden="true">*</span>}
      </label>
      <input
        id={inputId}
        type={type}
        required={required}
        aria-describedby={errorId}
      />
      <span id={errorId} className="error" role="alert">
        {/* エラーメッセージ */}
      </span>
    </div>
  );
}

4.2 アコーディオンコンポーネント

import { useId, useState } from 'react';

interface AccordionItemProps {
  title: string;
  children: React.ReactNode;
}

function AccordionItem({ title, children }: AccordionItemProps) {
  const [isOpen, setIsOpen] = useState(false);
  const headerId = useId();
  const panelId = useId();
  
  return (
    <div className="accordion-item">
      <h3>
        <button
          id={headerId}
          aria-expanded={isOpen}
          aria-controls={panelId}
          onClick={() => setIsOpen(!isOpen)}
        >
          {title}
        </button>
      </h3>
      <div
        id={panelId}
        role="region"
        aria-labelledby={headerId}
        hidden={!isOpen}
      >
        {children}
      </div>
    </div>
  );
}

4.3 モーダルダイアログ

import { useId, useEffect, useRef } from 'react';

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
}

function Modal({ isOpen, onClose, title, children }: ModalProps) {
  const titleId = useId();
  const descriptionId = useId();
  const dialogRef = useRef<HTMLDialogElement>(null);
  
  useEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;
    
    if (isOpen) {
      dialog.showModal();
    } else {
      dialog.close();
    }
  }, [isOpen]);
  
  return (
    <dialog
      ref={dialogRef}
      aria-labelledby={titleId}
      aria-describedby={descriptionId}
      onClose={onClose}
    >
      <h2 id={titleId}>{title}</h2>
      <div id={descriptionId}>
        {children}
      </div>
      <button onClick={onClose}>閉じる</button>
    </dialog>
  );
}

4.4 タブコンポーネント

import { useId, useState } from 'react';

interface Tab {
  label: string;
  content: React.ReactNode;
}

interface TabsProps {
  tabs: Tab[];
}

function Tabs({ tabs }: TabsProps) {
  const [activeIndex, setActiveIndex] = useState(0);
  const baseId = useId();
  
  return (
    <div className="tabs">
      <div role="tablist">
        {tabs.map((tab, index) => (
          <button
            key={index}
            id={`${baseId}-tab-${index}`}
            role="tab"
            aria-selected={activeIndex === index}
            aria-controls={`${baseId}-panel-${index}`}
            onClick={() => setActiveIndex(index)}
          >
            {tab.label}
          </button>
        ))}
      </div>
      {tabs.map((tab, index) => (
        <div
          key={index}
          id={`${baseId}-panel-${index}`}
          role="tabpanel"
          aria-labelledby={`${baseId}-tab-${index}`}
          hidden={activeIndex !== index}
        >
          {tab.content}
        </div>
      ))}
    </div>
  );
}

5. ベストプラクティス

5.1 正しい使い方

// ✅ アクセシビリティ属性に使用
function Input() {
  const id = useId();
  return (
    <>
      <label htmlFor={id}>Name:</label>
      <input id={id} />
    </>
  );
}

// ✅ 複数の要素にプレフィックスとして使用
function Form() {
  const id = useId();
  return (
    <>
      <input id={`${id}-first`} />
      <input id={`${id}-last`} />
    </>
  );
}

// ✅ カスタムフック内で使用
function useFormField(label: string) {
  const id = useId();
  return { id, labelProps: { htmlFor: id }, inputProps: { id } };
}

5.2 避けるべき使い方

// ❌ リストの key には使わない
function List({ items }) {
  const id = useId();
  return items.map((item, index) => (
    // 間違い: key はデータから生成すべき
    <li key={`${id}-${index}`}>{item.name}</li>
  ));
}

// ✅ 正しい key の使い方
function List({ items }) {
  return items.map((item) => (
    <li key={item.id}>{item.name}</li>
  ));
}

// ❌ CSS セレクタやクエリセレクタには不向き
function Component() {
  const id = useId();
  // useId は ":" を含むことがあるため、CSS セレクタで問題になる
  document.querySelector(`#${id}`);  // ❌ エラーになる可能性
}

5.3 SSR での注意点

// ⚠️ サーバーとクライアントでツリー構造を一致させる必要がある
function App() {
  // このような条件分岐は避ける
  if (typeof window === 'undefined') {
    return <ServerOnlyComponent />;
  }
  return <ClientComponent />;
}

// ✅ 代わりに useEffect で条件分岐
function App() {
  const [isClient, setIsClient] = useState(false);
  
  useEffect(() => {
    setIsClient(true);
  }, []);
  
  return isClient ? <ClientComponent /> : <ServerComponent />;
}

6. トラブルシューティング

6.1 ハイドレーションエラーが発生する

// ❌ 問題: サーバーとクライアントでツリーが異なる
function App() {
  return (
    <>
      {typeof window !== 'undefined' && <ClientOnly />}
      <Form />  {/* ID が一致しない */}
    </>
  );
}

// ✅ 解決策: Suspense と lazy を使用
const ClientOnly = lazy(() => import('./ClientOnly'));

function App() {
  return (
    <>
      <Suspense fallback={null}>
        <ClientOnly />
      </Suspense>
      <Form />
    </>
  );
}

6.2 異なる React アプリで ID が衝突する

// ❌ 問題: 同じページに複数のアプリがある場合
createRoot(document.getElementById('app1')).render(<App />);
createRoot(document.getElementById('app2')).render(<App />);
// 両方とも "_r_0_" から始まる ID が生成される

// ✅ 解決策: identifierPrefix を設定
createRoot(document.getElementById('app1'), {
  identifierPrefix: 'app1-'
}).render(<App />);

createRoot(document.getElementById('app2'), {
  identifierPrefix: 'app2-'
}).render(<App />);

6.3 ID に特殊文字が含まれて CSS セレクタで使えない

// ❌ 問題: useId は ":" を含むことがある(例: ":r0:")
const id = useId();
document.querySelector(`#${id}`);  // エラー

// ✅ 解決策1: CSS.escape を使用
document.querySelector(`#${CSS.escape(id)}`);

// ✅ 解決策2: data 属性を使用
function Component() {
  const id = useId();
  return <div data-id={id} />;
}
// document.querySelector(`[data-id="${id}"]`);

// ✅ 解決策3: ref を使用(推奨)
function Component() {
  const ref = useRef<HTMLDivElement>(null);
  // ref.current で直接アクセス
  return <div ref={ref} />;
}

7. まとめ

特徴 説明
一意性 コンポーネントツリー内で確実に一意
SSR 対応 サーバーとクライアントで同じ ID を生成
軽量 ビット演算ベースで高速に ID 生成
決定論的 ツリー構造から決定されるため予測可能

使用判断のフローチャート

ID が必要?
  ↓
アクセシビリティ属性(aria-*, htmlFor)に使う? ──Yes──→ ✅ useId を使用
  ↓ No
リストの key? ──Yes──→ ❌ データから生成(item.id など)
  ↓ No
CSS セレクタに使う? ──Yes──→ ⚠️ ref を検討、または CSS.escape を使用
  ↓ No
複数の React アプリが同一ページにある? ──Yes──→ identifierPrefix を設定
  ↓ No
SSR を使用? ──Yes──→ サーバーとクライアントで identifierPrefix を一致させる
1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?