ReactのSuspenseを入れ子にして、読み込み中の表示を段階的に出す方法

​​React​Suspense,TypeScript,use

Suspense を1つだけ置くと、中のデータがすべて届くまで、画面全体が「読み込み中…」のままになります。内側にもう1つ Suspense を置くと、先に届いた部分から画面に出して、遅い部分にだけ「読み込み中…」を残せます。

筆者は検証環境で、プロフィール・投稿・コメントの3つを読み込むページを作りました。Suspense の置き方を変えながら、Chromium で、データが届いた時刻と画面が切り替わった時刻を記録しています。

補足

検証環境は 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 です。.tsx を tsc で型チェックしてから、esbuild で本番用のビルドに変換しました。ブラウザは Chromium 141 を、Playwright 1.56.1 で操作しました。

検証用のページ

データの取得には、決めたミリ秒のあとに解決する Promise を使いました。use に渡す Promise は、再描画のたびに同じものを使う必要があるので、key ごとに1つだけ作って覚えておきます(use のリファレンスにある、Promise をキャッシュする説明)。

ts
// 検証用のデータ取得。ms ミリ秒後に解決する Promise を、key ごとに1つだけ作る。
const cache = new Map<string, Promise<string>>();

export function fetchData(key: string, ms: number): Promise<string> {
  let promise = cache.get(key);
  if (!promise) {
    log(`取得開始: ${key}`);
    promise = new Promise((resolve) => {
      setTimeout(() => {
        log(`データ到着: ${key}`);
        resolve(`${key} の中身`);
      }, ms);
    });
    cache.set(key, promise);
  }
  return promise;
}

Profile、Posts、Comments の3つのコンポーネントは、どれも use でこの Promise を読みます。待つ時間は URL のクエリで変えられるようにしました。

tsx
export function Profile({ children }: { children?: ReactNode }) {
  const text = use(fetchData('profile', wait('profile')));
  return (
    <section id="profile">
      <h2>プロフィール</h2>
      <p>{text}</p>
      {children}
    </section>
  );
}

画面の切り替わりは、MutationObserver で、表示されている要素の id が変わった時点を記録しました。ログの左の時刻は、ページのスクリプトが動き始めてからの経過時間です。

1つの Suspense は、中身が全部そろうまで待つ

まず、プロフィールと投稿を1つの Suspense で包みました。

tsx
function Single() {
  return (
    <Suspense fallback={<Loading id="page-loading" label="ページ" />}>
      <Profile />
      <Posts />
    </Suspense>
  );
}

プロフィールは 500 ミリ秒、投稿は 2000 ミリ秒で届くようにしました。

text
(URL のクエリ: layout=single&profile=500&posts=2000)
    5ms 取得開始: profile
   15ms 画面: page-loading
   17ms 取得開始: posts
  506ms データ到着: profile
 2017ms データ到着: posts
 2023ms 画面: profile + posts

プロフィールは 506 ミリ秒の時点で届いていますが、画面は 2023 ミリ秒まで「ページを読み込み中…」のままでした。Suspense のリファレンスにある、まとめて表示する説明のとおり、1つの Suspense の中身は1つのまとまりとして扱われ、全部が表示できるようになってから一度に出ます。

内側にもう1つ Suspense を置くと、届いた順に出る

投稿だけを、内側の Suspense で包みました。

tsx
function Nested() {
  return (
    <Suspense fallback={<Loading id="page-loading" label="ページ" />}>
      <Profile />
      <Suspense fallback={<Loading id="posts-loading" label="投稿" />}>
        <Posts />
      </Suspense>
    </Suspense>
  );
}

届く時間は、さっきと同じです。

text
(URL のクエリ: layout=nested&profile=500&posts=2000)
    1ms 取得開始: profile
    2ms 画面: page-loading
    2ms 取得開始: posts
  501ms データ到着: profile
  503ms 画面: profile + posts-loading
 2003ms データ到着: posts
 2004ms 画面: profile + posts

プロフィールが届いた 503 ミリ秒の時点で、プロフィールと「投稿を読み込み中…」が出ました。投稿は、届いた直後の 2004 ミリ秒の時点で「投稿を読み込み中…」と入れ替わっています。入れ子の Suspense の説明にある順番どおりです。外側の fallback が先に中身に替わり、そのあと内側の fallback が中身に替わります。

逆に、投稿のほうが先に届く場合(プロフィール 2000 ミリ秒、投稿 500 ミリ秒)も試しました。

text
(URL のクエリ: layout=nested&profile=2000&posts=500)
    1ms 取得開始: profile
    2ms 画面: page-loading
    2ms 取得開始: posts
  502ms データ到着: posts
 2001ms データ到着: profile
 2002ms 画面: profile + posts

内側の投稿は、外側のプロフィールより先には出ませんでした。プロフィールが届いた時点で、投稿もいっしょに出ています。「投稿を読み込み中…」は一度も出ていません。内側は外側の中にあるので、外側が表示されるまでは出ない、という順番は変わりません。

3段に重ねたときの動きを、ブラウザで撮りました。「ページを開く」を押すと、外側から順に「読み込み中…」が中身に替わります。新しく出た部分は、ピンクの枠で示しています。

「ページを開く」をクリックすると「ページを読み込み中…」が出る。約1秒でプロフィールと「投稿を読み込み中…」に替わり、約2.2秒で投稿と「コメントを読み込み中…」、約3.4秒でコメントが出る
クリックしてから、プロフィール、投稿、コメントの順に、外側から1段ずつ表示される動き

外側が待っている間も、内側のデータの取得は始まる

先ほどの、投稿だけを内側の Suspense で包んだときのログでは、プロフィールの取得を始めた直後(2 ミリ秒)に、投稿の取得も始まっています。外側の Suspense が「ページを読み込み中…」を出している間も、内側の Posts は描画され、fetchData が呼ばれていました。

これは React 19 の動きです。React 19 のアップグレードガイドにある、Suspense の改善の説明によると、コンポーネントが待ちに入ると、React はまずいちばん近い Suspense の fallback を画面に出します。そのあとで、待ちに入ったコンポーネントの兄弟を描画し、その中で必要になる読み込みを先に始めさせます(ガイドでは pre-warm と呼んでいます)。投稿だけを内側の Suspense で包んだ形では、Profile の兄弟にあたる内側の Suspense の中身が描画され、投稿の取得が始まりました。

待っているコンポーネントの中にある Suspense は、取得が遅れる

ただし、先に描画されるのは兄弟までです。待ちに入ったコンポーネントの子は、データが届くまで描画されません。

内側の Suspense を Profile の children として渡し、Profile の中に置いた場合を試しました。

tsx
function InsideProfile() {
  return (
    <Suspense fallback={<Loading id="page-loading" label="ページ" />}>
      <Profile>
        <Suspense fallback={<Loading id="posts-loading" label="投稿" />}>
          <Posts />
        </Suspense>
      </Profile>
    </Suspense>
  );
}
text
(URL のクエリ: layout=inside&profile=500&posts=2000)
    1ms 取得開始: profile
    2ms 画面: page-loading
  501ms データ到着: profile
  502ms 取得開始: posts
  503ms 画面: profile + posts-loading
 2502ms データ到着: posts
 2503ms 画面: profile + posts

投稿の取得は、プロフィールが届いた 502 ミリ秒の時点まで始まりませんでした。投稿が出たのは 2503 ミリ秒で、内側の Suspense を Profile と並べて置いたとき(2004 ミリ秒)より、約 500 ミリ秒遅くなっています。取得が順番待ちになったためです。

3段の入れ子でも同じことが起きます。Comments を、投稿を読む Posts の中に置くと、コメントの取得は投稿が届いた 1503 ミリ秒の時点まで始まらず、コメントが出たのは 4005 ミリ秒でした。

直し方の1つは、取得を外で先に始めて、Promise を渡すことです。ページのコンポーネントで fetchData を呼んでおき、Comments には Promise を渡して use で読ませました。

tsx
function ThreeEarly() {
  const comments = fetchData('comments', wait('comments')); // use で読む前に取得だけ始める
  return (
    <Suspense fallback={<Loading id="page-loading" label="ページ" />}>
      <Profile />
      <Suspense fallback={<Loading id="posts-loading" label="投稿" />}>
        <Posts>
          <Suspense fallback={<Loading id="comments-loading" label="コメント" />}>
            <CommentsFrom promise={comments} />
          </Suspense>
        </Posts>
      </Suspense>
    </Suspense>
  );
}
text
(URL のクエリ: layout=early&profile=500&posts=1500&comments=2500)
    2ms 取得開始: comments
    2ms 取得開始: profile
    2ms 画面: page-loading
    3ms 取得開始: posts
  502ms データ到着: profile
  504ms 画面: profile + posts-loading
 1503ms データ到着: posts
 1504ms 画面: profile + posts + comments-loading
 2502ms データ到着: comments
 2503ms 画面: profile + posts + comments

3つの取得がページを開いてすぐに始まり、コメントは 2503 ミリ秒で出ました。表示される順番は外側からのままで、待ち時間だけが短くなっています。

ポイント

Suspense を入れ子にしても、画面に出る順番は外側から内側です。データの取得まで順番待ちにならないよう、待ちに入るコンポーネントの子の中に、次のデータを読む Suspense を置くときは、取得を外で先に始めておきます。

300 ミリ秒以内に届いたものは、少し待ってから出る

Suspense のリファレンスの注意事項には、React が待っていた中身を表示するのは、前回表示してから数えて、300 ミリ秒に1回まで、と書かれています。その間に表示できるようになった境界は、1つずつではなく、まとめて表示されます。

投稿だけを内側の Suspense で包む形で、投稿が届く時刻を、プロフィールの 0〜500 ミリ秒後に変えて測りました。プロフィールが画面に出た時刻を 0 として、投稿が届いた時刻と、画面に出た時刻を並べたのが次のグラフです。

横軸が投稿のデータが届いた時刻、縦軸が投稿が画面に出た時刻のグラフ。届いたのが250ミリ秒までは、画面に出るのはどれも約300ミリ秒。289ミリ秒以降に届いたものは、届いてすぐに出ている
プロフィールが出てから投稿が届くまでの時間と、投稿が画面に出るまでの時間(Chromium で7通り測った結果)

プロフィールが出てから 250 ミリ秒以内に届いた投稿は、どれも、プロフィールが出てから約 300 ミリ秒の時点で画面に出ました。届いてすぐではなく、少し待たされています。289 ミリ秒後に届いた回は、300 ミリ秒を待たずに 290 ミリ秒で出ました。350 ミリ秒後と 501 ミリ秒後に届いた回も、届いてすぐに出ています。

内側の Suspense を2つ並べ、投稿とコメントを、プロフィールの約 100 ミリ秒後と約 200 ミリ秒後に届くようにすると、2つは同じ時点で出ました。

text
(URL のクエリ: layout=siblings&profile=500&posts=600&comments=700)
    4ms 取得開始: profile
   13ms 画面: page-loading
   15ms 取得開始: posts
   15ms 取得開始: comments
  505ms データ到着: profile
  512ms 画面: profile + posts-loading + comments-loading
  615ms データ到着: posts
  716ms データ到着: comments
  807ms 画面: profile + posts + comments

届く時刻をプロフィールの 500 ミリ秒後と 1000 ミリ秒後にして、間隔を 500 ミリ秒に広げると、投稿とコメントは、それぞれ届いた時点で別々に出ました。React が、近い時刻に表示できるようになったものをまとめて出していることが分かります。

1つの Suspense で、データが 100 ミリ秒で届く場合も試しました。この検証では、データが 100 ミリ秒で届いても、中身が出たのは「ページを読み込み中…」が出てから約 300 ミリ秒後の 302 ミリ秒でした。

補足

react-dom 19.3.1 の開発用ビルドには FALLBACK_THROTTLE_MS = 300 という値があり、注意事項の 300 ミリ秒と一致します。同じビルドには、待ち時間の残りが 10 ミリ秒以下なら待たずに出す条件もあります。計測に使った本番用ビルドにも、同じ条件があります。289 ミリ秒後に届いた回が待たずに出たのは、この条件によるものと考えられます。数え始めが React の中のどの時点なのかは、確かめていません。

Suspense を置く場所の決め方

入れ子の Suspense で決めているのは、どの部分をいっしょに出し、どの部分を先に出すかです。いっしょに出したい部分は1つの Suspense にまとめ、先に見せたい部分との間に Suspense を挟みます。

入れ子の Suspense の説明の最後には、すべてのコンポーネントを Suspense で包まず、境界は使う人に見せたい読み込みの段階より細かくしないように、と書かれています。検証でも、300 ミリ秒以内に届いた部分はまとめて出ました。細かく分けても、届く時刻が近ければ、段階的には見えません。

あわせて、取得を始める場所を確かめておきます。待ちに入るコンポーネントの子として、次のデータを読む Suspense を置くと、取得が順番待ちになります。投稿の Suspense を Profile の中に置いた場合や、Comments を Posts の中に置いた場合がそうでした。先に取得を始めて Promise を渡すと、表示の順番は変えずに待ち時間を短くできました。

​Posted by 86