# React 19 で大規模フロントを速く・アクセシブルに作る：コード分割・React Compiler・バンドル最適化・a11y/i18n の実践

> React 19で大規模SPA/Webアプリを高速かつアクセシブルに作る実装ガイド。lazy+Suspenseのルート単位コード分割で初期バンドルを縮小し、React Compilerの自動メモ化、manualChunksでのバンドル最適化、ARIA/キーボード操作/フォーカス管理/prefers-reduced-motionのa11y、そして多言語対応のスケールまでを、公式ドキュメントに忠実な実コードで解説します。

- 公開日: 2026-06-24
- 著者: 友田 陽大
- タグ: React, パフォーマンス, フロントエンド, a11y, TypeScript
- URL: https://tomodahinata.com/blog/react-19-large-scale-frontend-code-splitting-compiler-a11y-guide

## 要点

- 大規模フロントの不調は初期バンドル肥大・不要な再レンダー・a11y 欠如の3系統に集約され、①と③は同じ根を持つ
- ルートは lazy + Suspense で全部分割し、lazy は必ずトップレベル宣言する（関数内宣言は state リセットを招く）
- React Compiler で手動の useMemo / useCallback / memo を不要にする。前提は Rules of React 遵守
- No ARIA is better than bad ARIA。まずセマンティック HTML、ARIA は最小限の補完に留める
- i18n は1言語を正に他言語を同型で縛る型安全で翻訳漏れをビルド時に検出し、lang 属性で読み上げ言語を明示する

---

「画面が増えてきて、なんとなく重い」——大規模フロントエンドの不調は、たいていこの曖昧な一言から始まります。けれど中身を分解すると、敵は意外なほどはっきりしています。**初期バンドルが肥大している／不要な再レンダーが走っている／そもそもキーボードやスクリーンリーダーで使えない。** この3つです。

そして見落とされがちなのが、最後の「使えない（アクセシビリティ欠如）」もまた**性能と同じ設計問題の裏返し**だという点です。セマンティックでないDOMは、SEOにもパフォーマンスにもアクセシビリティにも同時に効きます。「速い」と「使える」は別の仕事ではありません。

この記事は、React 19 で**大規模なSPA / Webアプリ**を、速く・アクセシブルに・多言語で作るための実装ガイドです。題材として、私がチーム開発した[外国人旅行客向け飲食店マッチングサイト](/case-studies/restaurant-matching)——React + Framer Motion の Tinder 風スワイプUIを、**i18nライブラリを使わずマニュアル実装で日英中韓の4カ国語**に対応させ、アクセシブルに作った案件——の設計判断も交えます。

> **この記事のルール**：API名・挙動・属性名は **React / MDN / W3C の公式ドキュメント（2026年6月時点）** に基づきます。仕様は改定されるため、本番投入前に末尾の[公式リンク](#参考公式ドキュメント)で最新を必ず確認してください。そして大前提として——**速さとアクセシビリティは後付けの「対応」ではなく、最初からの「設計」**です。コードのシークレットは環境変数前提、`any` は使いません。

> **この記事のスコープ**：扱うのは**レンダリング性能とアクセシビリティ**です。サーバー状態のキャッシュ設計（`staleTime` / 無効化 / 楽観的更新）は別問題なので、[TanStack Query v5 実践ガイド](/blog/tanstack-query)に分離しました。Next.js のサーバー側キャッシュは[Next.js 16 App Router 実践ガイド](/blog/nextjs-16-app-router-cache-components-data-fetching)が補完です。**本記事ではデータ取得の話は意図的に最小限に留め**、レンダリングとa11yに集中します。

---

## 0. メンタルモデル：大規模フロントの3つの敵

設計に入る前に、地図を共有します。大規模フロントが重く・使いにくくなる原因は、ほぼ次の3系統に集約されます。

| 敵 | 症状 | 設計上の打ち手 |
| --- | --- | --- |
| ① 初期バンドルの肥大 | 初回表示が遅い／TTI が伸びる | **ルート単位のコード分割**（`lazy` + `Suspense`） |
| ② 不要な再レンダー | 操作のたびに画面が重い／カクつく | **React Compiler の自動メモ化**（手動 memo を不要に） |
| ③ アクセシビリティ欠如 | キーボードで操作できない／読み上げできない | **セマンティックHTML優先・ARIAは最小限・フォーカスと動きの配慮** |

重要なのは、**①と③は同じ根を持つ**ということ。たとえば「`<div onClick>` で作ったボタン」は、(a) ネイティブ`<button>`が持つキーボード操作・フォーカス・role を全部自前で再実装しないと使えず（③）、(b) 結果としてコード量とJSが増える（①）。逆に正しいセマンティックHTMLを選べば、両方が同時に軽くなる。**a11y を機能と同列の設計制約として扱う**——これが本記事の通奏低音です。

それでは①から順に潰していきます。

---

## 1. 初期バンドルを縮める：`lazy` + `Suspense` のルート分割

### 1.1 まず原則：「最初の画面に要らないコードは、最初に送らない」

大規模アプリの初期バンドルが太る最大の理由は、**全画面分のコンポーネントを1つのバンドルに同梱している**ことです。ユーザーがトップページしか見ていないのに、管理画面・設定画面・決済フローのコードまで一括ダウンロードさせている。これがTTI（操作可能になるまでの時間）を殺します。

打ち手はシンプルです。**画面（ルート）の境界でコードを分割し、必要になったときに動的`import()`で取りに行く。** React はこれを `lazy` で標準サポートします。公式の定義はこうです。

```js
const SomeComponent = lazy(load)
```

`load` は **Promise（または thenable）を返す関数**で、`.default` が有効なReactコンポーネントである必要があります。React は**最初にそのコンポーネントを描画しようとしたときだけ** `load` を呼び、**結果はキャッシュされるので `load` は一度しか実行されません**。

### 1.2 公式どおりの最小形

```tsx
import { lazy, Suspense } from "react";

// ✅ 必ずモジュールのトップレベルで宣言する
const MarkdownPreview = lazy(() => import("./MarkdownPreview"));

function Editor() {
  return (
    <Suspense fallback={<Loading />}>
      <h2>Preview</h2>
      <MarkdownPreview />
    </Suspense>
  );
}
```

公式が**明確に警告している落とし穴**が1つあります。

> **`lazy` は必ずモジュールのトップレベルで宣言すること。** コンポーネントの内部で宣言すると、再レンダーのたびに別物として扱われ、**state がリセットされます。**

```tsx
// 🔴 やってはいけない：再レンダーごとにstateが飛ぶ
function Editor() {
  const MarkdownPreview = lazy(() => import("./MarkdownPreview"));
  // ...
}
```

このバグは「たまにフォームが初期化される」のような再現性の低い不具合として現れ、調査が地獄です。**`lazy` はトップレベル固定**、これは例外なしの規律にしてください。

### 1.3 ルーターと組み合わせる：107ルートを遅延ロードする

実プロダクトでは、`lazy` を**ルーター定義の単位**で使うのが王道です。私が関わったB2B SaaS（製材業向け）では、**107ルートをすべて遅延ロード**して初期バンドルを最小化しました。各ルートが独立したチャンクになるため、ユーザーが踏んだ画面のJSだけが順に降ってきます。

```tsx
import { lazy, Suspense } from "react";
import { createBrowserRouter, RouterProvider } from "react-router";

// 各ルート = 各チャンク。トップレベルで宣言（state リセット回避）
const Dashboard = lazy(() => import("./routes/Dashboard"));
const Invoices = lazy(() => import("./routes/Invoices"));
const Settings = lazy(() => import("./routes/Settings"));

// ルート要素を Suspense でラップする小さなヘルパー（DRY）
function lazyRoute(Component: React.ComponentType) {
  return (
    <Suspense fallback={<RouteSkeleton />}>
      <Component />
    </Suspense>
  );
}

const router = createBrowserRouter([
  { path: "/", element: lazyRoute(Dashboard) },
  { path: "/invoices", element: lazyRoute(Invoices) },
  { path: "/settings", element: lazyRoute(Settings) },
]);

export function App() {
  return <RouterProvider router={router} />;
}
```

ここで `lazyRoute` という薄いヘルパーを切ったのは、**「Suspense でラップする」という知識を一箇所に集約する**ためです（DRY）。107個の `<Suspense>` を手で書くと、fallback の差し替えやエラーバウンダリ追加が地獄になります。境界の「形」が1関数に閉じていれば、変更が一点で済む（ETC）。

> **動的 `import()` がそのままチャンク分割になる**——これはバンドラ側の機能です。Vite（Rollup）でも webpack でも、`import("./X")` という構文を見つけると自動でX用の別チャンクを切り出します。Vite 公式も「コード分割された動的import呼び出しを**自動でpreloadステップに書き換える**」と述べており、非同期チャンクが共通チャンクを使う場合は**並列フェッチ**で取りに行ってくれます。つまり「分割しすぎてウォーターフォールが伸びる」古典的問題は、モダンバンドラがかなり緩和してくれます。

### 1.4 コード分割の「粒度」を決める：ルート単位 vs コンポーネント単位

「どこで割るか」は性能と複雑さのトレードオフです。割りすぎるとリクエスト数とチラつきが増え、割らなすぎると初期バンドルが太る。判断基準を表にします。

| 観点 | ルート単位で分割 | コンポーネント単位で分割 |
| --- | --- | --- |
| 主目的 | 初期バンドルの縮小（最優先） | 重い・たまにしか使わないUIの遅延 |
| 典型対象 | 各画面 / 各タブ | リッチエディタ、地図、グラフ、モーダル、PDF出力 |
| 効果 | 大きい（初回JSが画面分だけになる） | 中（特定の重いライブラリを後回しに） |
| リスク | ほぼなし（画面遷移は元々非同期） | 過剰分割でチラつき・ウォーターフォール |
| 判断 | **まず全ルートを割る** | **重い依存を持つ箇所だけ追加で割る** |

私の基本方針は **「ルートは全部割る、コンポーネントは重い依存があるところだけ割る」** です。たとえば Markdown プレビュー、リッチテキストエディタ、地図（Leaflet/Mapbox）、グラフ（Recharts 等）——これらは数百KBの依存を引き込むので、**実際に開くまで読み込まない**価値が大きい。逆に、ボタンやカードのような軽量コンポーネントを個別に `lazy` 化するのは、得られる削減 < チラつき・複雑さのコストで割に合いません（YAGNI）。

```tsx
// ✅ 重い依存を持つコンポーネントだけ追加で分割する
const RichTextEditor = lazy(() => import("./RichTextEditor")); // ~300KB
const MapView = lazy(() => import("./MapView")); // 地図ライブラリ込み

function ReviewForm() {
  const [editing, setEditing] = useState(false);
  return (
    <section>
      {editing ? (
        <Suspense fallback={<EditorSkeleton />}>
          <RichTextEditor />
        </Suspense>
      ) : (
        <button type="button" onClick={() => setEditing(true)}>
          レビューを書く
        </button>
      )}
    </section>
  );
}
```

「レビューを書く」を押すまでエディタのJSは降ってこない。これが**遅延の本質**——コードを消すのではなく、**必要になる瞬間まで先送りする**ことです。

---

## 2. `Suspense` を正しく置く：ローディングUXの設計

`lazy` は `Suspense` とセットで初めて意味を持ちます。`Suspense` の置き方そのものが、ユーザー体験の質を決めます。

### 2.1 `Suspense` の正確な仕様

公式によると `<Suspense>` のpropsは2つだけです。

- **`children`**：本来描画したいUI。これが描画中に**サスペンド**すると、境界は `fallback` に切り替わる。
- **`fallback`**：読み込み完了までの代替UI。スピナーやスケルトンなど軽量なプレースホルダ。

そして**Suspense を作動させるトリガー**は、公式が明示的に列挙しています。

> Suspense を作動させるのは **Suspense対応のデータソースのみ**です：
> - Relay や Next.js のような**Suspense対応フレームワークでのデータ取得**
> - **`lazy` によるコンポーネントコードの遅延ロード**
> - **`use` でキャッシュ済みPromiseの値を読む**こと
>
> **Effect やイベントハンドラ内で取得したデータは Suspense を検知しません。**

つまり「`useEffect` 内で `fetch` して `setState`」という従来パターンは Suspense では拾えない、ということ。本記事のスコープでは `lazy`（コード分割）でのサスペンドが主役です。

### 2.2 「まとめて出す」か「順に出す」か

`Suspense` の挙動で最も重要なのが、**境界の置き方で見え方が変わる**点です。公式の挙動を引用します。

> デフォルトでは、**Suspense の中のツリー全体が単一の単位**として扱われる。1つのコンポーネントだけがサスペンドしても、**全員まとめてfallbackに置き換わり**、全員の準備ができてから**一斉に**表示される。

```tsx
// この中身は「全員揃ってから一斉表示」
<Suspense fallback={<Loading />}>
  <Biography />
  <Panel>
    <Albums />
  </Panel>
</Suspense>
```

段階的に見せたいなら、`Suspense` を**ネスト**します。

```tsx
// BigSpinner → Biography 表示 → AlbumsGlimmer → Albums 表示、の順
<Suspense fallback={<BigSpinner />}>
  <Biography />
  <Suspense fallback={<AlbumsGlimmer />}>
    <Panel>
      <Albums />
    </Panel>
  </Suspense>
</Suspense>
```

設計判断はこうです。**「ページの骨格は即座に、重い部品は後から」なら境界を分ける。「中途半端な状態を見せたくない（フォームの一部だけ出ても困る）」なら1つの境界でまとめる。** UX要件から逆算して境界を引く——`Suspense` の数と位置は、見た目の段階性そのものです。

### 2.3 すでに見えているものを隠さない：`startTransition`

ありがちな事故が、**画面遷移のたびに全画面がfallbackに戻ってチラつく**こと。新しいルートのチャンクを取りに行く間、Suspense が既存の画面を隠してスピナーを出してしまうのです。

これを防ぐのが `startTransition` です。公式はこう述べます。

> `startTransition` で状態更新を**非緊急**としてマークすると、Suspense が**すでに見えているコンテンツを隠すのを防ぐ**。トランジション中、React は**不要なfallbackの出現を避けるために十分なデータが読み込まれるまで待つ**——ただし新しく描画されるSuspense境界は即座にfallbackを表示できる。

```tsx
import { useTransition } from "react";

function Nav() {
  const [isPending, startTransition] = useTransition();

  function navigate(url: string) {
    startTransition(() => {
      // 遷移中も「いま見えている画面」は隠れない
      setPage(url);
    });
  }

  return (
    <nav aria-busy={isPending}>
      {/* isPending を使って控えめなインジケータを出す */}
    </nav>
  );
}
```

`isPending` を `aria-busy` に渡しているのは伏線です——**スクリーンリーダーにも「いま読み込み中」を伝える**ためで、これは第5章のa11yに繋がります。「速い」設計の副産物が、そのまま「使える」設計になっている好例です。

---

## 3. React Compiler：不要な再レンダーを自動で消す

初期バンドルを縮めても、**操作のたびに重い**なら意味がありません。敵②「不要な再レンダー」を、React 19 は**コンパイラ**で自動的に潰しにいきます。

### 3.1 何をしてくれるのか

公式の定義はこうです。

> React Compiler は**ビルド時にアプリを自動最適化**する。手動の `useMemo` / `useCallback` / `React.memo()` を**自動の等価物で置き換える**ため、**コードを書き直す必要はない**。

最適化は大きく2系統です。

1. **コンポーネントの連鎖的な再レンダーをスキップ**：親が再レンダーしても、propsが実質変わらない子は再描画しない。
2. **コンポーネント外の高コスト計算をスキップ**：重い計算を自動でメモ化する。

要するに、これまで `useMemo` / `useCallback` / `React.memo` を手で貼って回っていた**「メモ化の認知コスト」が消える**。大規模フロントでは、この手作業の漏れと過剰こそが再レンダー問題の温床だったので、効果は大きいです。

### 3.2 「手動 memo が不要になる」の実例

Before（手動メモ化を貼り回る世界）:

```tsx
// 手で useMemo / useCallback / memo を貼らないと子が無駄に再描画する
const RestaurantList = React.memo(function RestaurantList({
  items,
  onSelect,
}: Props) {
  const sorted = useMemo(
    () => [...items].sort((a, b) => b.rating - a.rating),
    [items],
  );
  const handleSelect = useCallback((id: string) => onSelect(id), [onSelect]);
  return <List items={sorted} onSelect={handleSelect} />;
});
```

After（React Compiler 前提）:

```tsx
// コンパイラが自動でメモ化する。memo / useMemo / useCallback は不要
function RestaurantList({ items, onSelect }: Props) {
  const sorted = [...items].sort((a, b) => b.rating - a.rating);
  return <List items={sorted} onSelect={(id) => onSelect(id)} />;
}
```

After の方が**素直で、読みやすく、メモ化漏れも過剰メモ化も起こらない**。これは「コードの長期的価値」に直結する変化です。手動メモ化は、依存配列のズレで**バグの温床**にもなっていました。それが構造的に消えるのは大きい。

### 3.3 効果の限界と前提：「Rules of React 遵守」が条件

ここを誤解すると痛い目に遭うので、明確に書きます。**React Compiler は「Rules of React に従っている」ことを前提に動く魔法**です。公式の言葉を引きます。

> コンパイラは **Rules of React を理解している**ので、コードを書き直す必要はない。（中略）**本番展開できるかは、コードベースの健全性と、どれだけ Rules of React に従えているかに依存する。**

つまり、レンダー中にpropsやstateを破壊的に変更する／レンダー中に副作用を起こす、といった**ルール違反のコードは、コンパイラが安全に最適化できない**（最悪、挙動が変わる）。だから前提として——

- **`eslint-plugin-react-hooks`（Rules of React のリンタ）を入れて違反を潰す。**
- **`useMemo` / `useCallback` は「エスケープハッチ」として残せる**。公式も「既存のメモ化はそのまま残してよい（消すとコンパイル出力が変わり得るので、消すなら慎重にテストする）」と述べています。

### 3.4 部分的に切る：`"use no memo"` / `"use memo"`

コンパイラが対象外にすべき・一時的に外したい箇所には、**ディレクティブ**で制御します。公式が定義する正確な文字列リテラルは2つです。

- **`"use memo"`** — その関数（またはファイル）を**コンパイル対象に入れる**
- **`"use no memo"`** — **コンパイル対象から外す**

```tsx
// 関数単位で外す（デバッグ・非互換コードの隔離）
function LegacyChart() {
  "use no memo"; // ← 関数本体の先頭に置く
  // コンパイラが扱えない古い実装をここに隔離
  return <ThirdPartyChart />;
}
```

```tsx
// ファイル単位でオプトインする（全importより前、ファイル先頭）
"use memo";

function Component1() {
  return <div>Compiled</div>;
}
```

公式の**重要な注意**：ディレクティブは**文字列リテラルとして、関数本体の先頭 or モジュール先頭に置く必要があり**、位置がずれると**無視されます**。そして `"use no memo"` は**「なぜ外したか」を必ずコメントで残し、追跡issue付きの一時措置にすべき**——技術的負債を黙って埋めない、という規律そのものです。私は `"use no memo"` を見つけたら、必ず理由コメントと対応予定をセットで書きます。

> **コンパイルモード**：`annotation`（`"use memo"` 付きだけ）/ `infer`（コンパイラが判断、ディレクティブで上書き）/ `all`（全部、`"use no memo"` で除外）。**既存の大規模コードに後入れするなら、まず一部から始める**のが安全です（incremental adoption）。一気に全適用してルール違反が顕在化すると、原因の切り分けが難しくなります。

---

## 4. バンドル最適化：`manualChunks` でチャンクを設計する

`lazy` でルートは割れました。残るは**「共通の依存（vendor）」をどうチャンクに束ねるか**。ここを放置すると、どのルートを開いても同じ大きなライブラリ群が別々に乗ったり、キャッシュ効率が悪化したりします。Vite（Rollup）の `manualChunks` で制御します。

### 4.1 `output.manualChunks` の正確な仕様

Rollup 公式によると `output.manualChunks` は**2つの形**を取ります。

- **オブジェクト形式** `{ [chunkAlias: string]: string[] }`：キーがチャンク名、値が**そのチャンクに入れるモジュールの配列**。
- **関数形式** `(id: string) => string | void`：解決済みモジュールIDを受け取り、**返した文字列名のチャンクにそのモジュールと依存をまとめる**。`void` を返すと自動分割に任せる。

```ts
// vite.config.ts — オブジェクト形式（公式の最小例に忠実）
import { defineConfig } from "vite";

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          // lodash 配下を deep import しても1つの vendor チャンクにまとまる
          lodash: ["lodash"],
        },
      },
    },
  },
});
```

### 4.2 関数形式：node_modules を意味のある単位で束ねる

実務では**関数形式**が便利です。公式の定型は「`node_modules` を vendor にまとめる」。

```ts
// vite.config.ts — 関数形式
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks(id: string) {
          if (id.includes("node_modules")) {
            return "vendor"; // 依存をまとめて1チャンクに
          }
          // それ以外は Rollup の自動分割に委ねる
        },
      },
    },
  },
});
```

ただし**「全部vendor1個」はやりすぎ**になりがちです。重いライブラリ（地図、グラフ、エディタ、アニメーション）は**それを使うルートだけが読み込む**よう、別チャンクに切り出す方が効きます。

```ts
manualChunks(id: string) {
  if (id.includes("node_modules")) {
    // 重く・特定画面でしか使わない依存は独立チャンクに
    if (id.includes("framer-motion")) return "vendor-motion";
    if (id.includes("recharts") || id.includes("d3")) return "vendor-charts";
    // 安定して全画面で使う土台はまとめてキャッシュ効率を上げる
    if (id.includes("react") || id.includes("scheduler")) return "vendor-react";
    return "vendor"; // 残りの雑多な依存
  }
}
```

**設計指針**は2つだけ。

1. **安定して全画面で使う土台**（React 本体など）は1チャンクにまとめる → 内容が変わりにくく、**ブラウザキャッシュが長く効く**。
2. **重く・特定画面でしか使わない依存**は独立チャンクに切る → そのルートを踏んだ人だけが払う。

> **過剰分割に注意**：チャンクを細かく割りすぎると、HTTPリクエスト数とメタデータのオーバーヘッドが増え、かえって遅くなることがあります。**まず計測してから割る**——`vite build` の出力（各チャンクのサイズ一覧）や `rollup-plugin-visualizer` でバンドルを可視化し、**実際に太い依存だけを狙って分離**するのが鉄則です（推測でなく計測。YAGNI）。

---

## 5. アクセシビリティ：速さと同じ「設計」として組む

ここからが敵③です。そして冒頭で書いたとおり、**a11y はパフォーマンスと地続き**。正しいセマンティクスを選べば、コードは減り、SEOは上がり、誰でも使えるようになる。後付けの「対応」ではなく、最初からの設計です。

### 5.1 第一原則：「No ARIA is better than bad ARIA」

MDN が冒頭に置く、最も重要な警告から始めます。

> **「ARIAがないことは、悪いARIAよりもマシ（No ARIA is better than bad ARIA）」。** WebAIM の100万ページ調査では、**ARIAが存在するホームページは、ない場合より平均41%多くのエラーが検出された**。ARIAはアクセシビリティを高める目的の技術だが、誤用すると害の方が大きくなり得る。

そして**ARIAの第一原則**：

> **ネイティブのHTML要素・属性で必要なセマンティクスと挙動が既に組み込まれているなら、要素を流用してARIA role/state/propertyを足すのではなく、そのネイティブ要素を使え。**

具体例。進捗バーを `role="progressbar"` の `<div>` で自作するより、`<progress>` を使う方が圧倒的に正しい。

```tsx
// 🔴 自作 progressbar：role/value/min/max を全部自分で管理する羽目に
<div role="progressbar" aria-valuenow={75} aria-valuemin={0} aria-valuemax={100} />

// ✅ ネイティブ：キーボード・読み上げ・状態管理が最初から組み込み
<progress value={75} max={100}>75%</progress>
```

MDN が念を押すとおり、`<input>` や `<button>` などのネイティブ要素には**キーボードアクセシビリティ・role・state が組み込み済み**。これらを `<div>` + ARIA で「偽装」すると、**その挙動を全部スクリプトで自分が再現する責任**を負います。だから——**まずセマンティックHTML。ARIAは、ネイティブで表現できないときの最小限の補完。**

### 5.2 ARIA を「使う/使わない」の判断表

実装中、毎回この判断をします。表にして迷いをなくします。

| やりたいこと | まずネイティブで | ネイティブで無理なら |
| --- | --- | --- |
| クリックできる操作 | `<button type="button">` | （基本ネイティブで足りる） |
| ページ内リンク・遷移 | `<a href>` | — |
| 見出し・節構造 | `<h1>`〜`<h6>` / `<section>` / `<nav>` / `<main>` | — |
| フォーム入力とラベル | `<label>` + `<input>` | 視覚ラベルが無いとき `aria-label` |
| 進捗・範囲 | `<progress>` / `<input type="range">` | カスタムなら `role` + `aria-valuenow` 等 |
| 開閉トグル | `<details>` / `<summary>` | カスタムなら `aria-expanded` |
| 動的更新の通知（読み上げ） | （ネイティブに対応物なし） | `aria-live` リージョン |
| 装飾的な要素を読み上げ対象外に | （対応物なし） | `aria-hidden="true"` |

判断軸は一行で済みます。**「ネイティブHTMLに同じ意味の要素があるか？ あるなら使う。無いときだけARIAで補う。」** 表の右列（ARIA）に手が伸びたら、一拍置いて左列を確認する癖をつけてください。

### 5.3 必要十分な ARIA 属性

ネイティブで埋まらない隙間にだけ、MDN が挙げる属性を使います。

- **`aria-label`**：アクセシブルな名前を直接与える（アイコンのみのボタンなど、可視テキストが無いとき）。
- **`aria-labelledby`**：別要素を「この要素のラベル」として参照する。
- **`aria-describedby`**：別要素を「この要素の説明」として参照する（エラーメッセージの紐付けなど）。
- **`aria-live`**：ライブリージョン。**動的に変わる内容を支援技術に読み上げさせる**（トースト、検索結果件数など）。
- **`aria-hidden="true"`**：装飾要素をアクセシビリティツリーから隠す。

```tsx
// アイコンのみのボタン：可視テキストが無いので aria-label で名前を与える
<button type="button" aria-label="お気に入りに追加">
  <HeartIcon aria-hidden="true" /> {/* 装飾アイコンは読み上げ対象外に */}
</button>

// 検索結果件数：動的更新を読み上げさせる（aria-live）
<p aria-live="polite">{count} 件の店舗が見つかりました</p>

// 入力エラーを説明として紐付ける（aria-describedby）
<input id="email" aria-describedby="email-error" />
<p id="email-error" role="alert">メールアドレスの形式が正しくありません</p>
```

`HeartIcon` に `aria-hidden="true"` を付け、ボタン側に `aria-label` を置く——これで「ハート（装飾）」は読み上げず、「お気に入りに追加（意味）」だけが伝わる。**装飾と意味を分離する**のがコツです。

### 5.4 キーボード操作とフォーカス管理

マウスが使えない・使わないユーザーのために、**すべての操作はキーボードで完結**しなければなりません。ここでもネイティブ要素が効きます。`<button>` / `<a href>` / `<input>` は**デフォルトでフォーカス可能・Enter/Space で発火**。だから「`<div onClick>` を避けてネイティブを使う」だけで、キーボード対応の大半が無料で手に入ります。

ネイティブで足りない場面（カスタムウィジェット、モーダル）では `tabindex` とフォーカス制御を自分で書きます。

```tsx
// モーダル：開いたら中へフォーカスを移し、Escで閉じ、閉じたら呼び出し元へ返す
function Modal({ open, onClose, children }: ModalProps) {
  const dialogRef = useRef<HTMLDivElement>(null);
  const openerRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (!open) return;
    openerRef.current = document.activeElement as HTMLElement;
    dialogRef.current?.focus(); // 開いたら中へフォーカス移動
    return () => openerRef.current?.focus(); // 閉じたら元の要素へ返す
  }, [open]);

  if (!open) return null;
  return (
    <div
      ref={dialogRef}
      role="dialog"
      aria-modal="true"
      tabIndex={-1} // プログラムからフォーカス可能にする（Tab順には入れない）
      onKeyDown={(e) => {
        if (e.key === "Escape") onClose();
      }}
    >
      {children}
    </div>
  );
}
```

`tabIndex={-1}` は「**Tabキーの巡回には入れないが、`.focus()` でプログラム的にフォーカスできる**」の意味（`tabindex="0"` なら巡回に入れる）。実プロダクトでは、ARIA Authoring Practices（W3C の APG）の**ダイアログ・パターン**に沿って、フォーカストラップ（Tabが背景に抜けない）まで実装します。複雑なウィジェットを自作するときは、**APGの該当パターンを必ず参照**してください——車輪の再発明は事故の元です。

### 5.5 動きへの配慮：`prefers-reduced-motion`（スワイプUIの文脈）

ここが、私の飲食店マッチング案件の核心です。**Tinder風スワイプUIを React + Framer Motion** で実装したのですが、派手なアニメーションは、前庭障害のあるユーザーには**めまい・吐き気を引き起こす**ことがあります。MDN の `prefers-reduced-motion` は、OS設定で「動きを減らす」を選んだユーザーを検出するメディア特性です。

> 値は **`no-preference`** と **`reduce`** の2つ。`@media (prefers-reduced-motion)` は `@media (prefers-reduced-motion: reduce)` と等価。

CSS では、減らす設定のときにアニメーションを抑制します。

```css
/* 既定はアニメーションあり。"減らす"設定のときだけ抑制する */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}
```

Framer Motion のような**JSアニメーション**は、`matchMedia` で同じ設定を読み、アニメーションの内容自体を差し替えます。

```tsx
// 動きを減らす設定なら、スワイプの大きな移動アニメを「即時切り替え」に落とす
function usePrefersReducedMotion(): boolean {
  const [reduced, setReduced] = useState(false);
  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    setReduced(mq.matches);
    const onChange = (e: MediaQueryListEvent) => setReduced(e.matches);
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);
  return reduced;
}

function SwipeCard({ restaurant }: { restaurant: Restaurant }) {
  const reduced = usePrefersReducedMotion();
  return (
    <motion.div
      // 減らす設定なら派手なバネ運動を消し、一瞬で確定させる
      transition={reduced ? { duration: 0 } : { type: "spring", stiffness: 300 }}
      drag={reduced ? false : "x"}
    >
      {/* カード内容 */}
    </motion.div>
  );
}
```

**スワイプという機能自体は残しつつ、「動きの量」だけを設定に応じて落とす。** 機能を奪わずに、つらい人にはつらくない形にする——これが「動きの配慮」の正解です。アニメーションは飾りでなく**ユーザーの身体に作用するUI**である、という視点を持ってください。

### 5.6 a11y のチェック：自動 + 手動

a11y は「書いたら終わり」ではなく、**検証可能にする**のが本筋です（検証ファースト）。

- **自動チェック**：`axe`（`@axe-core/playwright` 等）をE2Eに組み込み、**コントラスト不足・ラベル欠落・role誤用などの機械検出可能な違反**をCIで弾く。このポートフォリオサイト自体も Playwright + axe でWCAGスイープを回しています。
- **手動チェック**：自動では拾えない部分——**Tabキーだけで全操作を完走できるか／スクリーンリーダー（VoiceOver / NVDA）で意味が通るか／フォーカスリングが見えるか**——を人の目と耳で確認する。

**自動チェックは「明らかな間違い」を消すが、「意味が通じるか」は人間にしか判定できない。** 両輪で回してください。axe が緑でも、Tabで操作不能なら失格です。

---

## 6. 多言語対応をスケールさせる：型安全なi18n

最後に i18n です。飲食店マッチングでは、**i18nライブラリを使わずマニュアル実装で日英中韓の4カ国語**に対応しました。「ライブラリ無し」で破綻させない鍵は、**型安全**です。

### 6.1 なぜ「ライブラリ無し」が成立したか

小〜中規模で、複数形・性差・複雑な書式が要らないなら、重いi18nライブラリは過剰になりがちです（YAGNI）。**辞書オブジェクト + 型で縛る**だけで、十分スケールします。要件が複雑化（ICU MessageFormat、言語別ロード分割など）したら、そのとき初めてライブラリを検討すればいい。**「2つで偶然、3つで初めてパターン」**——抽象化を急がない、という規律です。

### 6.2 型でキー欠落を防ぐ

マニュアル実装の最大のリスクは「ある言語だけ訳が抜ける」こと。これを**コンパイル時に**潰します。

```ts
// locales/index.ts — 1つの言語を「正」とし、他言語を同じ形に強制する
const ja = {
  "home.title": "近くの美味しいお店を見つけよう",
  "card.like": "いいね",
  "card.skip": "スキップ",
} as const;

// 基準となるキー集合の型（ja から導出 → 単一の真実の源 = DRY）
export type MessageKey = keyof typeof ja;
type Dictionary = Record<MessageKey, string>;

// ✅ en/zh/ko は Dictionary 型なので、キーが1つでも欠けると型エラー
const en: Dictionary = {
  "home.title": "Find great restaurants near you",
  "card.like": "Like",
  "card.skip": "Skip",
};
const zh: Dictionary = {
  "home.title": "发现附近的美食",
  "card.like": "喜欢",
  "card.skip": "跳过",
};
const ko: Dictionary = {
  "home.title": "근처 맛집을 찾아보세요",
  "card.like": "좋아요",
  "card.skip": "건너뛰기",
};

export const locales = { ja, en, zh, ko } as const;
export type Locale = keyof typeof locales;
```

`ja` を `as const` で固定し、そこから `MessageKey` を導出。`en`/`zh`/`ko` を `Dictionary` 型で縛れば、**1キーでも訳し忘れた瞬間に `tsc` が落ちる**。「翻訳漏れ」という最も多いi18nバグが、**実行時ではなくビルド時に**消えます。これが型安全の威力です（`any` を一切使わず、境界を型で固める）。

### 6.3 翻訳取得も型安全に

```ts
// t() のキーも MessageKey に縛る → 存在しないキーは補完されず、書けばエラー
export function createTranslator(locale: Locale) {
  const dict = locales[locale];
  return (key: MessageKey): string => dict[key];
}
```

```tsx
// 使う側。キー名は IDE 補完が効き、タイポは即エラーになる
function Card({ locale }: { locale: Locale }) {
  const t = createTranslator(locale);
  return (
    <article lang={locale}> {/* lang 属性で言語を明示 = a11y にも効く */}
      <h2>{t("home.title")}</h2>
      <button type="button">{t("card.like")}</button>
      <button type="button">{t("card.skip")}</button>
    </article>
  );
}
```

ここで `<article lang={locale}>` の **`lang` 属性**が重要です。これは**a11yとの接点**——スクリーンリーダーは `lang` を見て**正しい言語の音声エンジンで読み上げる**。`lang` が無いと、韓国語を日本語の発音で読み上げてしまう。**i18n と a11y は地続き**で、`lang` 属性はその結節点です。

### 6.4 スケール時の分割ロード

言語が増え、辞書が太ってきたら、**言語ファイルを `lazy`／動的 import で分割**します——本記事の第1章と同じ道具立てがそのまま使えます。

```tsx
// 必要な言語の辞書だけを動的に取りに行く（初期バンドルに全言語を載せない）
async function loadLocale(locale: Locale): Promise<Dictionary> {
  switch (locale) {
    case "ja": return (await import("./locales/ja")).default;
    case "en": return (await import("./locales/en")).default;
    case "zh": return (await import("./locales/zh")).default;
    case "ko": return (await import("./locales/ko")).default;
  }
}
```

**コード分割の技術が、そのままi18nのスケール戦略になる。** 第1章と同じ「必要になるまで送らない」の原則です。4言語くらいなら全部同梱でも問題ありませんが、10言語・大辞書になったら分割が効いてきます（これも計測してから）。

---

## 7. まとめ：速い・使える・多言語のチートシート

最後に、迷ったときの早見表です。

**① 初期バンドルを縮める（速さ）**
- ルートは**全部 `lazy` + `Suspense`** で割る。`lazy` は**必ずトップレベル宣言**（state リセット回避）。
- コンポーネント単位の分割は**重い依存があるところだけ**（エディタ・地図・グラフ）。
- `Suspense` の**境界の置き方＝見え方**。まとめて出すなら1境界、段階的ならネスト。
- 遷移で画面がチラつくなら **`startTransition` / `useTransition`** で既存UIを隠さない。

**② 再レンダーを消す（速さ）**
- **React Compiler** で手動 `useMemo` / `useCallback` / `React.memo` を不要にする。
- 前提は **Rules of React 遵守**。`eslint-plugin-react-hooks` で違反を潰す。
- 外したい箇所だけ **`"use no memo"`**（理由コメント＋追跡issue付きの一時措置）。

**③ アクセシブルにする（使える）**
- **No ARIA is better than bad ARIA**。まずセマンティックHTML、ARIAは最小限の補完。
- アイコンボタンは `aria-label`、装飾は `aria-hidden`、動的更新は `aria-live`。
- **キーボードで全操作完走**。モーダルはフォーカス移動・Esc・呼び出し元へ復帰。
- **`prefers-reduced-motion`** で動きを抑制（機能は奪わず、動きの量だけ落とす）。
- 検証は **axe（自動）＋ Tab/スクリーンリーダー（手動）の両輪**。

**④ 多言語をスケールさせる（使える）**
- 小〜中規模はライブラリ無しでOK。**1言語を正に、他を同型で縛る型安全**で翻訳漏れをビルド時に検出。
- **`lang` 属性**で読み上げ言語を明示（i18n × a11y の結節点）。
- 辞書が太ったら**動的 import で分割**——コード分割と同じ道具。

---

「速い」と「使える」は、別々の作業ではありません。**正しいセマンティクスを選び、必要なものだけを必要なときに送る**——この一つの設計判断が、パフォーマンスとアクセシビリティの両方を同時に良くします。a11y を「あとで対応するもの」に追いやった瞬間、コードは太り、SEOは落ち、誰かが使えなくなる。最初から設計に織り込むのが、結局いちばん速くて安い。

私は外国人旅行客向けの飲食店マッチングサイトで、**Framer Motion のスワイプUIを4カ国語で・アクセシブルに**実装し、別のB2B SaaSでは**107ルートの遅延ロード**と**`any` 禁止・Zod 境界検証の型安全規律**でフロントをスケールさせてきました。生成AI（Claude Code）を相棒に**一人で速く**作りつつ、a11y と型安全という**人間の検証ゲート**は外さない——それが私の作り方です。

**「大規模で・速くて・誰でも使えて・多言語の React フロントを作りたい」——その設計から実装・計測まで、一気通貫で伴走できます。** 要件整理の段階からでも、お気軽にご相談ください。

---

### 参考（公式ドキュメント）

- [React `lazy`](https://react.dev/reference/react/lazy) — `lazy(load)` の仕様・トップレベル宣言の必須ルール・Suspense連携
- [React `<Suspense>`](https://react.dev/reference/react/Suspense) — props（children / fallback）・トリガー条件・段階表示・startTransition
- [React Compiler を学ぶ](https://react.dev/learn/react-compiler) — 自動メモ化・Rules of React 前提・段階的導入
- [React Compiler ディレクティブ](https://react.dev/reference/react-compiler/directives) — `"use memo"` / `"use no memo"` の正確な挙動と配置
- [Rollup `output.manualChunks`](https://rollupjs.org/configuration-options/#output-manualchunks) — オブジェクト形式・関数形式のチャンク制御
- [Vite Features](https://vite.dev/guide/features) — 動的 import のチャンク分割・自動 preload・glob import
- [MDN: ARIA](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA) — No ARIA is better than bad ARIA・第一原則・主要属性
- [W3C: ARIA Authoring Practices Guide](https://www.w3.org/WAI/ARIA/apg/) — ダイアログ等のキーボード/フォーカスパターン
- [MDN: `prefers-reduced-motion`](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion) — 値（no-preference / reduce）・CSS と matchMedia
