C
Next.js/レンダリング/Lesson 07

Streaming + Suspense — ページを一括ではなく部分ごとに配信する

35分·theory
このチャプター
4/5
TypeScript

Streaming + Suspense — ページを一括ではなく部分ごとに配信する

💡 なぜ学ぶ必要があるのか? — 最も遅いfetchがページ全体をブロックする

🎯 Server Component 内で `await fetch()` を行うと、そのコンポーネントが完了するまでページ全体が待機します。ヘッダーはすぐに表示できるはずなのに、コメントの fetch に 5 秒かかるためヘッダーも 5 秒待たされます。
💼 `` 境界で囲むと、その内側だけが個別にロードされ、残りの部分はすぐに表示されます — これが「プログレッシブ HTML ストリーミング」です。
Next.js は同じフォルダ内の `loading.tsx` を自動的に `` でラップします。フォルダ単位のローディング UX を実現できます。
🔗 複数の fetch を並列で起動し (Promise.all ではなく)、それぞれの到着に合わせて順次表示します — 最も速い部分からユーザーに表示されます。
📈 React 18+ のコア機能 — 「ウォーターフォールなしのデータローディング」を実現します。
🏢 실무에서는
ショッピングページ: 商品情報(速い) + レビュー(中程度) + おすすめ商品(遅い)。従来の方法では、おすすめ商品の fetch が完了するまでページ全体が表示されず、ユーザーは 4〜5 秒間空白画面を見ることになります。Suspense で 3 つを分離すると: 商品情報 0.3 秒 → レビュー 1 秒 → おすすめ 3 秒 — 各部分が到着次第すぐに表示されます。体感速度が劇的に改善されます。

Suspense境界・loading.tsx・段階的ストリーミング

1. Suspense境界の動作原理

tsx
import { Suspense } from 'react';

export default function Page() {
  return (
    <div>
      <Header />  {/* 即時レンダリング */}
      <Suspense fallback={<ReviewSkeleton />}>
        <Reviews />  {/* Reviewsがawaitしている間はReviewSkeletonを表示 */}
      </Suspense>
      <Suspense fallback={<RecommendSkeleton />}>
        <Recommend />  {/* Recommendも独立してロード */}
      </Suspense>
    </div>
  );
}

async function Reviews() {
  const reviews = await fetch('https://api/reviews').then(r => r.json());
  return <ul>{reviews.map(r => <li key={r.id}>{r.text}</li>)}</ul>;
}
  • HeaderはすぐにHTMLとして送信されます。
  • ReviewsとRecommendのスロットはfallbackで埋められます。
  • 各コンポーネントのfetchが完了すると、そのHTMLがその場にストリーミングされます(</html>の閉じタグの後でも追加可能)。

2. loading.tsx — フォルダ単位の自動Suspense

code
app/posts/
├── page.tsx
└── loading.tsx  ← page.tsx全体が自動的に<Suspense fallback={<Loading />}>でラップされる

ページ単位のローディング処理として最も手軽な方法。明示的なimportは不要。

3. 明示的な vs. loading.tsx

ツール適した状況
loading.tsxページ全体がデータ待ち — 空ページ+スピナーで十分
直接<Suspense>ページの一部だけが遅い — 残りはすぐに表示したい

実務では両方を組み合わせて使用します。loading.tsxで大枠を、ページ内でを使って部分的に分割します。

4. 段階的ストリーミングの視覚的効果

code
時間 →
0.1秒: <html><body><Header />...スケルトン...</body>
0.3秒:                ...Reviews到着...
1.0秒:                                 ...Recommend到着...

ユーザー体験:「何かすぐ表示された」(Header 0.1秒)→「レビューが来た」(0.3秒)→「おすすめも来た」(1秒)。旧来の方式なら1秒間の空白画面の後に一度に全て表示されるところが改善されます。

5. ウォーターフォールの回避 — 並列fetch+並列Suspense

tsx
// ❌ 直列ウォーターフォール — Reviewsが終わるまでRecommendは開始できない
export default async function Page() {
  const reviews = await getReviews();
  const recommend = await getRecommend();
  return <div>{reviews.length}件のレビュー、{recommend.length}件のおすすめ</div>;
}

// ✅ Suspenseで並列化 — 両方が同時にfetchを開始
export default function Page() {
  return (
    <>
      <Suspense fallback={<S1 />}><Reviews /></Suspense>
      <Suspense fallback={<S2 />}><Recommend /></Suspense>
    </>
  );
}
// ReviewsとRecommendはそれぞれ非同期コンポーネント — 同時に開始される

6. エラー境界とのペアリング — error.tsx

tsx
<ErrorBoundary fallback={<E />}>
  <Suspense fallback={<S />}>
    <Reviews />  {/* throwした場合はErrorBoundaryが捕捉し、awaitした場合はSuspenseが捕捉する */}
  </Suspense>
</ErrorBoundary>

App Routerではerror.tsxが自動的にErrorBoundaryの役割を担います。

💻 🅰️ Suspenseなし — 最も遅いfetchが全体をブロックする
// ❌ 全体 await — 遅いコンポーネントが速い部分もブロック

// 📁 app/product/[id]/page.tsx
export default async function ProductPage({ params }: { params: { id: string } }) {
  // 3つが順次 (または Promise.all で並列でも3つすべてが完了するまでレンダーされない)
  const product = await fetch(`/api/products/${params.id}`).then(r => r.json());
  const reviews = await fetch(`/api/reviews?productId=${params.id}`).then(r => r.json());
  const recommend = await fetch(`/api/recommend/${params.id}`).then(r => r.json());

  // 3つすべてが到着して初めてこの return が実行される
  // 最も遅いものが3秒かかる場合、ユーザーは3秒間空白画面を見ることに
  return (
    <div>
      <h1>{product.name}</h1>
      <Reviews items={reviews} />
      <Recommend items={recommend} />
    </div>
  );
}
💻 🅱️ Suspense境界で分割 — 到着次第すぐに表示
// ✅ Suspense境界で分割 — それぞれ到着次第ストリーミング

import { Suspense } from 'react';

// 📁 app/product/[id]/page.tsx
export default async function ProductPage({ params }: { params: { id: string } }) {
  // 高速なデータのみawait — ヘッダーを即時表示
  const product = await fetch(`/api/products/${params.id}`).then(r => r.json());

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.price}円</p>

      {/* 遅い部分はSuspenseで囲み、段階的に表示 */}
      <Suspense fallback={<ReviewSkeleton />}>
        <Reviews productId={params.id} />
      </Suspense>

      <Suspense fallback={<RecommendSkeleton />}>
        <Recommend productId={params.id} />
      </Suspense>
    </div>
  );
}

// 子コンポーネント — それぞれasync
async function Reviews({ productId }: { productId: string }) {
  const reviews = await fetch(`/api/reviews?productId=${productId}`).then(r => r.json());
  return (
    <ul>
      {reviews.map((r: { id: number; text: string }) => (
        <li key={r.id}>{r.text}</li>
      ))}
    </ul>
  );
}

async function Recommend({ productId }: { productId: string }) {
  const items = await fetch(`/api/recommend/${productId}`).then(r => r.json());
  return <ul>{items.map((i: { id: number; name: string }) => <li key={i.id}>{i.name}</li>)}</ul>;
}

function ReviewSkeleton() {
  return <div className="animate-pulse">レビューを読み込み中...</div>;
}

function RecommendSkeleton() {
  return <div className="animate-pulse">おすすめを読み込み中...</div>;
}

// 📁 app/product/[id]/loading.tsx — ページ初回アクセス時 (ヘッダーも描画できない場合)
export default function Loading() {
  return <div className="animate-pulse">商品情報を読み込み中...</div>;
}

// ユーザー体感:
// 0.1秒: ヘッダー + スケルトン表示 (HTML一部到着)
// 0.5秒: レビューが埋まる (HTML追加ストリーミング)
// 2.0秒: おすすめが埋まる (HTML追加ストリーミング)
// → 空白画面時間 0.1秒 (vs 従来の方式 3秒)

💡 💡 Streaming + Suspense 実践5箇条

1. loading.tsxはページ全体、は部分的に
ページの90%がデータ待ちであればloading.tsxで十分。一部だけが遅い場合はで部分分割します。

2. Suspenseが捕捉するのは非同期サーバーコンポーネントのみ
クライアントコンポーネントのuseStateのデータはSuspenseでは捕捉されません(それはuseTransition / useDeferredValueの領域です)。

3. Suspense内のawaitは自動的にthrow → Reactが捕捉
明示的なthrowやtry/catchは不要。async functionをそのまま書くだけでOKです。

4. ウォーターフォールの回避 — 各コンポーネントが自身のデータをfetch
親ですべてのデータを集めてからpropsで子に渡すとウォーターフォールが発生します。各コンポーネントが自身のデータをfetchすると、Reactが並列処理します。

5. Suspense境界はErrorBoundaryとペアで使う

awaitがrejectされるとErrorBoundaryが捕捉します — Suspenseの内側ではなく外側で。App Routerのerror.tsxが自動的にこれを処理します。

code
<ErrorBoundary fallback={<E />}>      ← error.tsxが自動で対応
  <Suspense fallback={<S />}>           ← loading.tsxまたは明示的に指定
    <AsyncComponent />
  </Suspense>
</ErrorBoundary>

⚡ 実際に試してみよう — Suspense段階的ストリーミングシミュレーション

各コンポーネントの到着時間に応じて、HTMLが段階的に描画される様子をシミュレーションします。
✏️ JS 코드
📟 コンソール出力
▶ 実行ボタンを押してください
⚠️ ブラウザのサンドボックスで実行 — console.log()のみ対応、alert/fetchは不可

理解度チェッククイズ

App Routerでページ全体がデータロード中の場合、最もシンプルな方法は何ですか?
💡 `loading.tsx` を同じフォルダに配置するだけで、Next.js が page.tsx 全体を自動的に `<Suspense fallback={<Loading />}>` でラップします — import や登録コードは一切不要です。ページの一部だけが遅い場合は、明示的な `<Suspense>` 境界で部分的に分割します。両方を組み合わせて使用することもできます。
Streaming + Suspense — 段階的HTMLストリーミング - Next.js