lazyとSuspenseでコンポーネントを後から読み込む方法

​​React​Error Boundary,esbuild,startTransition,Suspense,TypeScript

React の lazy で包んだコンポーネントは、初めて表示されるときまでコードを読み込みません。設定画面のように、開かない人もいる部分を別のファイルに分けておけば、最初に読み込む JavaScript を減らせます。読み込みを待つあいだの表示は、Suspense で決めます。

筆者は検証環境で、lazy で読み込むコンポーネントを Chromium と jsdom の上で動かしました。ファイルを読み込む時点、default 以外の export の読み込み方、先に読み込みを始める方法、読み込みに失敗したときの動きを確かめています。

補足

検証環境は 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 です。ブラウザは Chromium 141 を、Playwright 1.56.1 で操作しました。.tsx を tsc で型チェックしてから esbuild で変換しました。React は開発用のビルドです。ブラウザで読み込む JavaScript は、検証用の小さなサーバーから配りました。

lazy と Suspense の書き方

読み込みを後回しにしたいコンポーネントは、別のファイルに分けて default で export しておきます。ここでは、通知の設定を切り替える Settings を使います。

tsx
// Settings.tsx
import { useState } from 'react';

export default function Settings() {
  const [notify, setNotify] = useState(false);
  return (
    <section id="settings">
      <h2>設定</h2>
      <label>
        <input
          id="notify"
          type="checkbox"
          checked={notify}
          onChange={(e) => setNotify(e.target.checked)}
        />
        通知を受け取る
      </label>
    </section>
  );
}

使う側では、import Settings from './Settings' と書く代わりに、lazy に import() を返す関数を渡します。表示する場所は Suspense で包み、読み込み中に出すものを fallback に渡します。

tsx
import { lazy, Suspense, useState } from 'react';
import { createRoot } from 'react-dom/client';

const Settings = lazy(() => import('../Settings'));

function App() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button id="toggle" onClick={() => setOpen(!open)}>
        {open ? '閉じる' : '設定を開く'}
      </button>
      {open && (
        <Suspense fallback={<p id="loading">読み込み中…</p>}>
          <Settings />
        </Suspense>
      )}
    </>
  );
}

createRoot(document.getElementById('root')!).render(<App />);

lazy のリファレンスにある引数の説明によると、React が lazy に渡した関数(load)を呼ぶのは、そのコンポーネントを初めて表示しようとしたときです。返ってきた Promise とその結果は覚えておくので、load を2回以上呼ぶことはありません。Promise の結果の .default を、コンポーネントとして表示します。

import() で読み込む先を別のファイルに分けるには、バンドラーやフレームワークの対応が必要な場合があります(lazy のリファレンスにある使い方の説明)。検証では esbuild の --splitting を使いました。esbuild --help に「currently only for esm」とあるとおり、--format=esm と組み合わせます。変換すると、Settings は chunks/Settings-CDNPFV2N.js という別のファイルになりました。basic.js と Settings が共通で使う react パッケージは、chunks/chunk-2TCA6HDD.js に分かれています(react-dom は basic.js に入っています)。

ボタンを押したときに、初めてファイルを読み込む

上のコードで作った画面を Chromium で開き、どの時点でファイルを読み込むかを記録しました。違いが見えるよう、Settings のファイルだけ、サーバーが 800 ミリ秒遅らせて返すようにしています。

text
--- B1. ボタンを押すまで、Settings のファイルは読み込まれない(応答を 800ms 遅らせる) ---
     22ms 要求: basic.js
     46ms 応答: basic.js(200)
     48ms 要求: chunks/chunk-2TCA6HDD.js
     53ms 応答: chunks/chunk-2TCA6HDD.js(200)
   1156ms (開いてから 1 秒たっても、Settings は要求されていない)
   1156ms > 「設定を開く」をクリック
   1188ms 要求: chunks/Settings-CDNPFV2N.js
   1988ms 応答: chunks/Settings-CDNPFV2N.js(200)
   2496ms 設定が表示された(読み込み中…: 出た、クリックから 810ms)
   2500ms > 「閉じる」をクリック
   2513ms > 「設定を開く」をクリック
   2855ms 設定が表示された(読み込み中…: 出なかった、クリックから 1ms)

左の時刻は、計測用のスクリプトが行を書いた時点です。表示を確かめる処理の時間も含むため、左の時刻の差は「クリックから ○ms」と一致しません。「クリックから ○ms」は、ページの中で、クリックから設定が出るまでを測った値です。

ページを開いた時点で読み込んだのは、basic.js と react の入った共通のファイルだけでした。Settings のファイルは、ボタンを押してから要求され、届くまでは「読み込み中…」が出ていました。閉じてもう一度開いたときは、ファイルの要求も「読み込み中…」もなく、すぐに表示されました。

同じ操作を、画面の下に「ブラウザが読み込んだ JS ファイル」を並べて撮ったのが次の GIF です。この GIF では、読み込み中の表示が見えるよう、Settings のファイルを 1.2 秒遅らせています。

1回目に「設定を開く」を押すと「読み込み中…」が出て、Settings のファイルが一覧に加わり、設定が表示される。2回目のクリックで閉じ、3回目に開くと、ファイルは増えず、すぐに設定が表示される
1回目のクリックで Settings のファイルを読み込み、3回目のクリックでは読み込まずにすぐ表示されるまでの動き

default 以外で export したコンポーネントを読み込む

load が返す Promise は、.default にコンポーネントが入ったオブジェクトに解決される必要があります(lazy のリファレンスにある、load が返す値の説明)。名前を付けて export したコンポーネントは、then で { default: コンポーネント } の形に包み直します。

tsx
// Profile.tsx は export function Profile() { ... } と書いている
const Profile = lazy(() =>
  import('./Profile').then((m) => ({ default: m.Profile })),
);

検証では、呼ばれた回数を数える行を足したうえで、この書き方で Profile が表示されることを確かめました。入れ替えずに lazy(() => import('../src/Profile')) と書いた場合は、型チェックで次のエラーになりました。TypeScript で書いていれば、実行する前に気づけます。

text
bad/NoDefault.tsx(4,28): error TS2322: Type 'Promise<typeof import("./src/Profile")>' is not assignable to type 'Promise<{ default: ComponentType<any>; }>'.
  Property 'default' is missing in type 'typeof import("./src/Profile")' but required in type '{ default: ComponentType<any>; }'.

lazy はコンポーネントの外で宣言する

lazy のリファレンスのトラブルシューティングには、lazy をコンポーネントの中で宣言しないように、と書かれています。再描画のたびに state がリセットされるためです。代わりに、モジュールのいちばん上で宣言するよう書かれています。

jsdom の上で、コンポーネントの中で lazy を呼ぶ書き方を試しました。設定のチェックを入れてから、親を再描画しています。

tsx
export function LazyInside() {
  const [n, setN] = useState(0);
  // 🔴 描画のたびに、新しい lazy コンポーネントができる
  const InsideSettings = lazy(loadSettings);
  return (
    <>
      <button id="rerender" onClick={() => setN(n + 1)}>親を再描画({n})</button>
      <Suspense fallback={<p>読み込み中…</p>}>
        <InsideSettings />
      </Suspense>
    </>
  );
}

loadSettings は、呼ばれた回数を数え、100 ミリ秒待ってから import('./Settings') を返す、検証用の関数です。結果は次のとおりです。

text
--- 3b. lazy をコンポーネントの中で宣言 ---
(中略)
  > #rerender をクリック
  [load を呼んだ(2回目)]
  画面: <button id="rerender">親を再描画(1)</button><section id="settings" style="display: none !important;"><h2>設定</h2><label><input id="notify" type="checkbox">通知を受け取る</label></section><p>読み込み中…</p>
  (「読み込み中…」が消えるまで 300 ミリ秒)
  画面: <button id="rerender">親を再描画(1)</button><section id="settings" style=""><h2>設定</h2><label><input id="notify" type="checkbox">通知を受け取る</label></section>
  チェック: true
  load の呼び出し: 2回

親を再描画しただけで load がもう一度呼ばれ、表示中の設定が隠れて「読み込み中…」に戻りました。コンポーネントの外(モジュールのいちばん上)で宣言した版では、親を再描画しても load は呼ばれず、表示もそのままでした。

一方、チェックは入ったままで、この検証では state はリセットされませんでした。読み込んだ先が、表示中のコンポーネントと同じ関数だったためと考えられます。react-dom の開発用ビルドには、新しい lazy の中身が今のコンポーネントと同じなら、表示中のコンポーネント(とその state)をそのまま使い回す条件があります(react-dom-client.development.js の、前と今の要素を比べる処理)。ただ、ドキュメントは state がリセットされると書いており、残ることを当てにはできません。再描画のたびに「読み込み中…」に戻るだけでも困るので、外で宣言します。

先に読み込みを始めて、待ち時間を減らす

MDN の import() の説明にある、モジュール名前空間オブジェクトの項によると、ブラウザは読み込みに成功したモジュールを覚えておくので、同じモジュールをもう一度 import() しても、HTTP の要求は出ません。そこで、import() を呼ぶ関数を分けておき、ボタンにカーソルが乗ったときやフォーカスが来たときにも呼んでおきます。

tsx
// import を呼ぶ関数を分けておき、lazy とボタンの両方から使う
const loadSettings = () => import('../Settings');
const Settings = lazy(loadSettings);

function App() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button
        id="toggle"
        onPointerEnter={() => { loadSettings(); }}
        onFocus={() => { loadSettings(); }}
        onClick={() => setOpen(!open)}
      >
        {open ? '閉じる' : '設定を開く'}
      </button>
      {open && (
        <Suspense fallback={<p id="loading">読み込み中…</p>}>
          <Settings />
        </Suspense>
      )}
    </>
  );
}

Chromium で、カーソルを乗せてから 300 ミリ秒後にクリックした結果です。

text
--- B2b. カーソルを乗せてから 300ms でクリックした場合(応答を 800ms 遅らせる) ---
(中略)
    647ms > 「設定を開く」にカーソルを乗せる
    692ms 要求: chunks/Settings-CDNPFV2N.js
    994ms > 「設定を開く」をクリック
   1496ms 応答: chunks/Settings-CDNPFV2N.js(200)
   1814ms 設定が表示された(読み込み中…: 出た、クリックから 490ms)

カーソルを乗せた時点で要求が出て、クリックしたときには要求は増えませんでした。クリックから表示までは 490 ミリ秒で、何もしない場合(810 ミリ秒)より短くなっています。

ただし、カーソルを乗せて 1 秒待ち、ファイルが届いてからクリックしても、「読み込み中…」が短いあいだ出て、表示まで 305 ミリ秒かかりました。初めて表示するとき、lazy は自分で load を呼びます。モジュールが読み込み済みでも、import() が返すのは新しい Promise です。React はその結果を待つため、いったん Suspense の fallback が出ると考えられます。Suspense のリファレンスの注意事項によると、React は、Suspense の中身を前回表示してから 300 ミリ秒たつまで、次の表示を待たせます。react-dom の開発用ビルドでも、この間隔は FALLBACK_THROTTLE_MS = 300 です。305 ミリ秒かかったのは、この間隔に関係していると考えられます。ただし、どの時点から数えているのかは確かめていません。

この短い「読み込み中…」も出したくないときは、最初から表示されているボタンごと Suspense で包み、setOpen を startTransition で包みます。同じ注意事項のとおり、startTransition による更新では、表示済みの中身を fallback に戻さずに待つからです。

Suspense の位置も変えるのは、トランジションが新しく出てくる Suspense の境界を待たないためです(表示済みの中身を隠さないようにする方法の説明)。前の書き方では、Suspense は {open && ...} の中にあり、開いたときに新しく出てきます。この説明のとおりなら、トランジションで包んでも fallback が出ることになります。この組み合わせは試していません。

tsx
function App() {
  const [open, setOpen] = useState(false);
  return (
    // Suspense を、最初から表示されている部分の外側に置く
    <Suspense fallback={<p id="loading">読み込み中…</p>}>
      <button
        id="toggle"
        onPointerEnter={() => { loadSettings(); }}
        onFocus={() => { loadSettings(); }}
        onClick={() => startTransition(() => setOpen(!open))}
      >
        {open ? '閉じる' : '設定を開く'}
      </button>
      {open && <Settings />}
    </Suspense>
  );
}
text
--- B2c. Suspense を外側に置き、setOpen を startTransition で包んだ場合(応答を 800ms 遅らせる) ---
(中略)
   1702ms > 「設定を開く」をクリック
   2050ms 設定が表示された(読み込み中…: 出なかった、クリックから 17ms)

カーソルを乗せて 1 秒待ってからクリックすると、「読み込み中…」は出ず、17 ミリ秒で表示されました。カーソルを乗せてから 300 ミリ秒で、ファイルが届く前にクリックした場合も、「読み込み中…」は出ませんでした。届くまでのあいだは、前の画面(「設定を開く」のボタン)が出たままでした。

text
--- B2d. Suspense を外側に置き、startTransition で包んだ画面で、届く前にクリック(応答を 800ms 遅らせる) ---
(中略)
    706ms > 「設定を開く」にカーソルを乗せる
    749ms 要求: chunks/Settings-CDNPFV2N.js
   1050ms > 「設定を開く」をクリック
   1291ms 画面: 設定を開く
   1553ms 応答: chunks/Settings-CDNPFV2N.js(200)
   1613ms 画面: 閉じる設定通知を受け取る
  読み込み中…: 出なかった

ファイルが届くまでのあいだ、押したことが分かる表示を出したいときは、useTransition の isPending を使います(useTransition のリファレンス)。

読み込みに失敗したとき

load が返した Promise が失敗すると、React はその理由を、いちばん近い Error Boundary に投げます(lazy のリファレンスにある引数の説明)。ネットワークが切れていてファイルを取れなかった場合などに、代わりの表示を出せます。

困るのは、失敗のあとで読み込み直すときです。jsdom の上で、1回目だけ失敗する load を作って試しました。Error Boundary を元に戻すだけでは、同じエラーがすぐに出て、load は呼ばれませんでした。リファレンスには、lazy は返された Promise を覚えておくとあり、検証の結果から、失敗した Promise も覚えていることが分かります。

text
--- 4a. 失敗したあと、同じ lazy のまま Error Boundary を戻す ---
(中略)
  > #retry をクリック
  [onCaughtError] 読み込みに失敗
  画面: <div role="alert"><p>読み込めませんでした(読み込みに失敗)</p><button id="retry">もう一度</button></div>
(中略)
  load の呼び出し: 1回

そこで、lazy を state に入れておき、「もう一度」で作り直すようにしました。useState に初期化の関数を渡して作るので、描画のたびに作り直すことはありません(useState のリファレンスにある、初期値を作り直さないようにする方法)。

tsx
const loadSettings = () => import('../Settings');

function SettingsArea() {
  const [Settings, setSettings] = useState<ComponentType>(() => lazy(loadSettings));
  return (
    <ErrorBoundary
      fallback={({ reset }) => (
        <div id="failed" role="alert">
          <p>読み込めませんでした</p>
          <button
            id="retry"
            onClick={() => {
              setSettings(() => lazy(loadSettings));
              reset();
            }}
          >
            もう一度
          </button>
        </div>
      )}
    >
      <Suspense fallback={<p id="loading">読み込み中…</p>}>
        <Settings />
      </Suspense>
    </ErrorBoundary>
  );
}

ErrorBoundary は、reset で state を戻せるようにした、自作のクラスコンポーネントです。1回目だけ失敗する load を使った jsdom の検証(上のコードとほぼ同じ形で、load と失敗したときの表示を差し替えたもの)では、作り直した lazy が load をもう一度呼び、設定が表示されました。

ところが Chromium で、サーバーが1回目の要求に 500 を返すようにして試すと、「もう一度」を押しても表示は戻りませんでした。

text
--- B3. 1回目の要求を失敗させ、lazy を作り直して読み込み直す ---
(中略)
    104ms 要求: chunks/Settings-CDNPFV2N.js
    113ms 応答: chunks/Settings-CDNPFV2N.js(500)
    443ms 画面: 読み込めませんでしたもう一度
    446ms > 「もう一度」をクリック
   2079ms 画面: 読み込めませんでしたもう一度
   2083ms > ページを読み込み直す
(中略)
   2135ms 要求: chunks/Settings-CDNPFV2N.js
   2237ms 応答: chunks/Settings-CDNPFV2N.js(200)
   2943ms 画面: 設定通知を受け取る

「もう一度」を押したあと、Settings のファイルの要求は出ていません。同じファイルへの import() が、新しく要求を出さずに、また失敗したと考えられます(作り直した lazy が load を呼ぶことは、jsdom の検証で確かめています)。ページを読み込み直すと、ファイルが要求され、設定が表示されました。

MDN の同じ項には、読み込みに失敗したモジュールは、次の import() で読み込み直されることがある、と書かれています。少なくとも検証に使った Chromium 141 では、読み込み直しは起きませんでした。

注意

Chromium 141 では、サーバーが 500 を返して一度失敗したファイルは、lazy を作り直しても取りに行きませんでした。lazy を作り直すだけでは直らないので、失敗したときの表示には「ページを読み込み直す」ための案内やボタンを入れておくと安全です。ほかのブラウザでの動きは確かめていません。

ポイント

lazy はモジュールのいちばん上で宣言し、表示する場所を Suspense で包みます。読み込みは初めて表示するときに始まり、一度読み込んだら覚えておきます。先に import() を呼んでおくと待ち時間を減らせます。失敗は Error Boundary で受け止められます。ただし、検証した Chromium 141 では、lazy を作り直しても読み込み直せず、ページを読み込み直すと表示できました。

​Posted by 86