メインコンテンツへスキップ
友田 陽大
フロントエンド
React
パフォーマンス
フロントエンド
a11y
TypeScript

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

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

公開日
読了時間
30分
著者
友田 陽大
シェア
目次

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

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

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

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

この記事のスコープ:扱うのはレンダリング性能とアクセシビリティです。サーバー状態のキャッシュ設計(staleTime / 無効化 / 楽観的更新)は別問題なので、TanStack Query v5 実践ガイドに分離しました。Next.js のサーバー側キャッシュはNext.js 16 App Router 実践ガイドが補完です。本記事ではデータ取得の話は意図的に最小限に留め、レンダリングと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 で標準サポートします。公式の定義はこうです。

const SomeComponent = lazy(load)

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

1.2 公式どおりの最小形

import { lazy, Suspense } from "react";

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

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

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

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

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

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

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

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

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)。

// ✅ 重い依存を持つコンポーネントだけ追加で分割する
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の設計

lazySuspense とセットで初めて意味を持ちます。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に置き換わり、全員の準備ができてから一斉に表示される。

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

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

// 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を表示できる。

import { useTransition } from "react";

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

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

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

isPendingaria-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(手動メモ化を貼り回る世界):

// 手で 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 前提):

// コンパイラが自動でメモ化する。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"コンパイル対象から外す
// 関数単位で外す(デバッグ・非互換コードの隔離)
function LegacyChart() {
  "use no memo"; // ← 関数本体の先頭に置く
  // コンパイラが扱えない古い実装をここに隔離
  return <ThirdPartyChart />;
}
// ファイル単位でオプトインする(全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.manualChunks2つの形を取ります。

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

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

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

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> を使う方が圧倒的に正しい。

// 🔴 自作 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":装飾要素をアクセシビリティツリーから隠す。
// アイコンのみのボタン:可視テキストが無いので 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>

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

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

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

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

// モーダル:開いたら中へフォーカスを移し、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-preferencereduce の2つ。@media (prefers-reduced-motion)@media (prefers-reduced-motion: reduce) と等価。

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 で同じ設定を読み、アニメーションの内容自体を差し替えます。

// 動きを減らす設定なら、スワイプの大きな移動アニメを「即時切り替え」に落とす
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 型でキー欠落を防ぐ

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

// 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;

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

6.3 翻訳取得も型安全に

// t() のキーも MessageKey に縛る → 存在しないキーは補完されず、書けばエラー
export function createTranslator(locale: Locale) {
  const dict = locales[locale];
  return (key: MessageKey): string => dict[key];
}
// 使う側。キー名は 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章と同じ道具立てがそのまま使えます。

// 必要な言語の辞書だけを動的に取りに行く(初期バンドルに全言語を載せない)
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 フロントを作りたい」——その設計から実装・計測まで、一気通貫で伴走できます。 要件整理の段階からでも、お気軽にご相談ください。


参考(公式ドキュメント)

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事で解説した技術の適用事例

外国人旅行客向け飲食店マッチングサイト(React + Framer Motion のスワイプUI・4カ国語対応をアクセシブルに実装)

ケーススタディを見る