アプリの規模が大きくなると、「最初の表示がなかなか出ない」「まだ使っていない画面のコードまで一緒に読み込んでいる」といった悩みが出てきます。これを解決するのが React の lazy と Suspense です。必要になったときにコンポーネントを読み込む「遅延読み込み(コード分割)」で、初期表示を軽くできます。この記事では、なぜコード分割が効くのか、lazy と Suspense の基本、ルート・モーダル・タブでの実用例、名前付きエクスポートの扱い、読み込み失敗への備えまで、初心者〜中級者向けに解説します。
目次
なぜコンポーネントを遅延読み込みするのか
React アプリは通常、ビルド時にすべてのコンポーネントを1つの大きな JavaScript ファイル(バンドル)にまとめます。ページを開くと、ブラウザはまずこのバンドル全体をダウンロードして解析します。バンドルが大きいほど、この最初の処理に時間がかかり、画面が表示されるまでの待ち時間(初期表示)が長くなります。
ここで問題なのは、その画面ではまだ使わないコンポーネントまで一緒に読み込んでいる点です。たとえば、開かれるまで見えないモーダルや、クリックされて初めて表示される別ページのコードを、最初から全部ダウンロードする必要はありません。そこで「コードを分割し、実際に必要になったときにその部分だけ読み込む」という考え方が生まれます。これがコード分割(code splitting)であり、それを React で実現する道具が lazy と Suspense です。
lazy と Suspense の基本
lazy は、コンポーネントを「使うときになって初めて読み込む」ように包む関数です。引数には、動的インポート(import())を返す関数を渡します。import() は指定したモジュールを別ファイルとして分割し、必要になったタイミングで読み込む構文です。
import { lazy, Suspense } from "react";
// Heavy.tsx は別ファイルに分割され、表示されるときに読み込まれる
const Heavy = lazy(() => import("./Heavy"));
function App() {
return (
<Suspense fallback={<p>読み込み中...</p>}>
<Heavy />
</Suspense>
);
}
ポイントは2つです。1つ目は lazy(() => import("./Heavy")) で遅延読み込みするコンポーネントを作ること。2つ目は、それを Suspense で囲むことです。Suspense は、内側のコンポーネントがまだ読み込み中の間、fallback に渡した内容(ここでは「読み込み中…」)を代わりに表示してくれます。読み込みが終われば、自動的に本来のコンポーネントに差し替わります。
読み込み中の表示には、テキストだけでなくスピナー(くるくる回るアイコン)などの部品を渡すのが一般的です。fallback には通常の JSX をそのまま書けます。
<Suspense fallback={<Spinner />}>
<Heavy />
</Suspense>
ルート単位で遅延読み込みする
もっとも効果が大きいのは、ページ(ルート)ごとの分割です。ユーザーは最初に1つのページしか見ないので、他のページのコードは実際にそのページへ移動したときに読み込めば十分です。ルーティングと組み合わせると、次のように書けます。
import { lazy, Suspense } from "react";
import { Routes, Route } from "react-router-dom";
// 各ページをルートごとに分割して読み込む
const Home = lazy(() => import("./pages/Home"));
const About = lazy(() => import("./pages/About"));
function AppRoutes() {
return (
<Suspense fallback={<Spinner />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
</Routes>
</Suspense>
);
}
この書き方では、最初に / を開いたときには Home のコードだけが読み込まれ、ユーザーが /about へ移動して初めて About のコードがダウンロードされます。ページ数が多いアプリほど、初期表示の軽量化に効いてきます。
モーダルやタブなど「開くまで見えないUI」で使う
遅延読み込みが役立つのはルートだけではありません。開くまで表示されないモーダルや、切り替えるまで見えないタブの中身も、良い分割の候補です。特にモーダルの中身が重い(グラフやエディタなどを含む)場合、開くまで読み込みを遅らせる価値があります。
import { lazy, Suspense, useState } from "react";
// 設定モーダルの中身は、開くまで読み込まない
const SettingsModal = lazy(() => import("./SettingsModal"));
function SettingsButton() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>設定を開く</button>
{/* open が true になったときだけ読み込みが始まる */}
{open && (
<Suspense fallback={<Spinner />}>
<SettingsModal onClose={() => setOpen(false)} />
</Suspense>
)}
</>
);
}
open が false の間は SettingsModal が JSX に登場しないため、コードは読み込まれません。ボタンが押されて open が true になった瞬間に読み込みが始まり、その間だけ fallback のスピナーが表示されます。タブ切り替えでも同じ発想で、選択中のタブの中身だけを lazy で読み込むように書けます。
名前付きエクスポートを lazy で読み込む
lazy は、読み込んだモジュールの default エクスポートをコンポーネントとして扱います。そのため、export default ではなく名前付きエクスポート(export function Chart のような形)のコンポーネントは、そのままでは読み込めません。この場合は、import() の結果を加工して、目的のコンポーネントを default に詰め替えます。
// Chart.tsx が「export function Chart() {...}」の場合
// import した後、Chart を default として返すオブジェクトに変換する
const Chart = lazy(() =>
import("./Chart").then((module) => ({ default: module.Chart })),
);
import() はモジュール全体を表すオブジェクトを解決します。その中から使いたい module.Chart を取り出し、{ default: ... } という形にして返すのがコツです。lazy は返されたオブジェクトの default を見るので、これで名前付きエクスポートも遅延読み込みできます。
読み込みに失敗したときへの備え
遅延読み込みは、実際にはネットワーク越しに追加のファイルを取得します。通信が不安定だと、この取得に失敗することがあります。読み込みが失敗すると、Suspense の内側でエラーが投げられますが、Suspense 自体はこのエラーを受け止めません。
そこで、Error Boundary(エラーバウンダリ)という仕組みを併用します。これは配下のコンポーネントで発生したエラーを捕まえて、代わりのUI(「読み込みに失敗しました。再試行してください」など)を表示するためのものです。Suspense を Error Boundary で囲んでおくと、通常時は fallback で読み込み中を、失敗時はエラー用のUIを、と役割を分けられます。実装の詳細は本記事の範囲を超えますが、「読み込み失敗にはエラーバウンダリで備える」という組み合わせがある、と覚えておくと安心です。
使うときの注意点
lazy コンポーネントは必ず Suspense の内側に置く
lazy で作ったコンポーネントは、読み込みが終わるまで「まだ描画できない」状態になります。この待機状態を受け止めるのが Suspense です。Suspense で囲まずに lazy コンポーネントを描画しようとするとエラーになります。必ず、どこか上位に Suspense がある状態で使ってください。1つの Suspense で複数の lazy コンポーネントをまとめて囲むこともできます。
lazy の呼び出しはコンポーネントの外で行う
const Heavy = lazy(...) は、コンポーネント関数の内側ではなく、モジュールのトップレベル(ファイルの一番外側)で書きます。コンポーネントの中で lazy を呼ぶと、再レンダリングのたびに新しいコンポーネントが作られてしまい、そのたびに状態が失われたり、読み込みがやり直しになったりします。次の書き方は避けてください。
function App() {
// NG: レンダリングのたびに Heavy が作り直される
const Heavy = lazy(() => import("./Heavy"));
return (
<Suspense fallback={<Spinner />}>
<Heavy />
</Suspense>
);
}
正しくは、最初の例のように import 文の並びと同じ、ファイルのトップレベルで一度だけ lazy を呼び出します。こうすることで、コンポーネントの定義が使い回され、状態も保たれます。
まとめ
lazy と Suspense は、コンポーネントを「使うときになって読み込む」ことで初期表示を速くする、React 標準のコード分割の仕組みです。lazy(() => import("./Heavy")) で遅延読み込みするコンポーネントを作り、<Suspense fallback={...}> で囲んで読み込み中の表示を出します。効果が大きいのはルート単位の分割ですが、モーダルやタブなど「開くまで見えないUI」でも有効です。名前付きエクスポートは default に詰め替えて読み込み、読み込み失敗にはエラーバウンダリで備えます。lazy は必ず Suspense の内側で使い、呼び出しはモジュールのトップレベルで行う、という2点を守れば安全に導入できます。バンドルが大きくて初期表示が重いと感じたら、まずはページ単位の遅延読み込みから試してみてください。