Next.js において loading.js と Suspense を組み合わせた時の挙動まとめ

2024/08/17に公開

Next.js(AppRouter)での開発をしていてローディングが想定通りに動かず困ったシーンがあったので、改めて loading.js と Suspense を併用した際にどのような挙動となるのかを確認してみることにした。

確認方法

内部で非同期処理を行うコンポーネントを用意し、これらを特定のページ(/sample) で呼び出す。
その際に以下のパターンでページが表示される様子を確認する。

パターン loading.js Suspense
0 ❌ なし ❌ なし
1 ✅ あり ❌ なし
2 ❌ なし ✅ あり
3 ✅ あり ✅ あり

※ なるべくシンプルな状態で確認するために loading.js はアプリケーションルートでは配置せず、 /sample 配下にのみ配置する

非同期処理を行うコンポーネントは以下のように内部でJSONPlaceholderのAPIを叩く。わかりやすいようにそれぞれ数秒遅延させてPromiseを解決する。

export const Post = async () => {
  async function delayedFetch(url: string): Promise<Response> {
    return new Promise((resolve) => {
      setTimeout(async () => {
        const response = await fetch(url);
        resolve(response);
      }, 3000); // 3秒の遅延
    });
  }

  const result = await delayedFetch("https://jsonplaceholder.typicode.com/posts/1");
  const data: Post = await result.json();

  return <p>{data.title}</p>;
};
export const Comment = async () => {
  async function delayedFetch(url: string): Promise<Response> {
    return new Promise((resolve) => {
      setTimeout(async () => {
        const response = await fetch(url);
        resolve(response);
      }, 5000); // 5秒の遅延
    });
  }

  const result = await delayedFetch("https://jsonplaceholder.typicode.com/comments/1");
  const data: Comment = await result.json();

  return <p>{data.name}</p>;
};
// /sample/page.js
export default function SamplePage() {
  return (
    <>
      <section>
        <h2>POST</h2>
        <Post />
      </section>
      <section>
        <h2>COMMENT</h2>
        <Comment />
      </section>
    </>
  );
}

パターン0: loading.jsなし / Suspenseなし

まずは loading.js や Suspense を利用しない状態でどうなるのかを確認してみる。

💡結果

全てのPromiseが解決されるまで遷移先のページは表示されなかった。

パターン1: loading.jsあり / Suspenseなし

💡結果

遷移しようとするとすぐに loading.js の中身が表示され、全ての Promise が解決されると SamplePage コンポーネントの中身が表示された

パターン2: loading.jsなし / Suspenseあり

loading.js がない状態でデータフェッチを行っているコンポーネントを Suspense で囲う

2-1:全てのコンポーネントをSuspenseで囲う

export default function SamplePage() {
  return (
    <>
      <section>
        <h2>POST</h2>
        <Suspense fallback={<div>Loading Post...</div>}>
          <Post />
        </Suspense>
      </section>
      <section>
        <h2>COMMENT</h2>
        <Suspense fallback={<div>Loading Comment...</div>}>
          <Comment />
        </Suspense>
      </section>
    </>
  );
}

💡結果

データフェッチを行わない要素がすぐに表示され、Suspense で囲まれたコンポーネントについては、Promise が解決されるまではフォールバックが表示された。

Promise が解決され次第、各コンポーネントが順次表示された。

2-2: 片方のコンポーネントのみSuspenseで囲う

export default function SamplePage() {
  return (
    <>
      <section>
        <h2>POST</h2>
        <Post />
      </section>
      <section>
        <h2>COMMENT</h2>
        <Suspense fallback={<div>Loading Comment...</div>}>
          <Comment />
        </Suspense>
      </section>
    </>
  );
}

💡結果

Suspense で囲っていないコンポーネントのPromiseが解決されるまではページは表示されず、Promiseが解決された時点でページが表示される。

このタイミングで Suspense で囲われているコンポーネントは、Promise が解決されるまでフォールバック UI が表示され、Promise が解決され次第実際のコンテンツが順次表示された。

パターン3: loading.jsあり / Suspenseあり

loading.js がある状態でデータフェッチを行っているコンポーネントを Suspense で囲う

3-1: 全てのコンポーネントをSuspenseで囲う

💡結果

パターン2-1の時同様の動きとなる。

3-2: 片方のコンポーネントのみSuspenseで囲う

💡結果

Suspense で囲っていないコンポーネントのPromiseが解決されるまでは loading.js によるフォールバックが表示される。

Suspense で囲っていないコンポーネントのPromiseが解決されるとSamplePage コンポーネントが表示されるが、このタイミングで Suspense で囲っているコンポーネントのPromiseが未解決の場合はそのコンポーネントはフォールバックが表示され、Promiseが解決したタイミングで追って中身が表示される。

結論

表示しようとするページ内に非同期処理(Promise)が含まれる場合:

  • 通常、すべての Promise が解決されるまではページ全体が表示されない
  • しかし、loading.js がある場合は Promise が解決されるまでの間 loading.js が表示される。
  • Suspense が使われている場合は、Promise が未解決でもページ全体は表示され、Suspense で囲った部分だけがフォールバックに置き換わる。

つまり、

  • loading.js はページ単位のローディング状態を管理するために使われる
  • Suspense はページ内の特定のコンポーネントのレンダリングを遅延させるために使われる

Discussion