非同期サーバーコンポーネントのエラーがErrorBoundaryにキャッチされなかったので原因調べてみた

非同期サーバーコンポーネントのエラーがErrorBoundaryにキャッチされなかったので原因調べてみた

2026.02.27

はじめに

Next.jsでチャートコンポーネントを実装していた際、APIからデータを取得するasync付きサーバーコンポーネントをErrorBoundaryで囲んでいたのですが、APIが404を返すと画面全体がクラッシュしてしまいました。

Suspenseを追加したら直ったのですが、なぜSuspenseがないとダメなのかが気になって調べてみました。

遭遇した状況

APIからデータを取得して表示する非同期な(async)サーバーコンポーネントがありました。

async function AuthorChart({ slug }: { slug: string }) {
  const data = await fetchAuthorStats(slug) // 404だとthrowされる
  return <Chart data={data} />
}

APIが404を返した場合に備えて、ErrorBoundaryでエラーハンドリングしていました。

<ErrorBoundary fallback={<p>チャートを読み込めませんでした</p>}>
  <AuthorChart slug={slug} />
</ErrorBoundary>

しかし実際に404が発生するとErrorBoundaryのfallbackは表示されず、ページ全体がエラーで落ちてしまいました。

解決したコード

<ErrorBoundary fallback={<p>チャートを読み込めませんでした</p>}>
  <Suspense fallback={null}>
    <AuthorChart slug={slug} />
  </Suspense>
</ErrorBoundary>

原因を理解する

先輩の助言でSuspenseを追加したら問題は解決したのですが、「なぜSuspenseがないとErrorBoundaryが機能しないのか?」 という疑問が湧きました。

SuspenseやErrorBoundary周りの仕様を調べていく中で、公式情報の中で記載のあるものはどれもクライアントコンポーネントに関するものばかりで、サーバーコンポーネントにおける事象の説明はみつけられませんでした。

この疑問への答えが、そのままサーバーコンポーネントとSuspense、ErrorBoundaryの関係性についての解説になりますので、以下に書いていきます。

なぜSuspenseがないとダメなのか

Next.js App Routerでは、サーバー側でサーバーコンポーネントの実行結果をRSC Payloadにシリアライズし、そこからHTMLを生成してクライアントに送信します。このHTMLの生成・ストリーミングを担うのがReactのrenderToPipeableStreamです。

renderToPipeableStreamでは、ReactツリーがSuspenseバウンダリ内とシェル(Suspenseの外のエリア)の2つの領域に分かれます。

function Page() {
  return (
    <Layout>                    {/* ← シェル */}
      <Header />                {/* ← シェル */}
      <Suspense fallback={<Spinner />}>
        <AuthorChart />         {/* ← Suspenseバウンダリ内 */}
      </Suspense>
      <Footer />                {/* ← シェル */}
    </Layout>
  )
}
  • シェル: Suspenseバウンダリの外側。最初にHTMLとして出力され、ストリーミングの起点になる
  • Suspenseバウンダリ内: 非同期に解決でき、最初はfallbackがHTMLとして送信される。解決後にストリーミングで実際のコンテンツに置換される

Suspenseなしの場合

<ErrorBoundary fallback={<p>チャートを読み込めませんでした</p>}>
  <AuthorChart slug={slug} />   {/* ← シェルの一部 */}
</ErrorBoundary>

AuthorChartはSuspenseに囲まれていないため、シェルの一部として扱われます。

ここで重要なのが、async付きサーバーコンポーネントの処理結果はRSC Payloadのストリーム上で非同期に送信されるという点です。renderToPipeableStreamがHTMLを生成する時点では、AuthorChartの処理結果はまだ届いておらず、未解決の状態です。

Reactは未解決のデータを読み取ろうとすると、ErrorではなくPromiseをthrowします(ReactFlightClient.jsreadChunk。厳密にはReact内部のthenableオブジェクト)。そしてReactのレンダリングエンジンは、throwされた値の型によって処理先を振り分けます(ReactFiberThrow.js)。

  • Promiseがthrowされた場合Suspenseが処理する
  • Errorがthrowされた場合ErrorBoundaryが処理する

async付きサーバーコンポーネントが未解決の段階でthrowされるのはPromiseであり、ErrorBoundaryの守備範囲外です。Suspenseがあればfallbackを表示して待機できますが、Suspenseがなければ処理する受け皿がなく、シェルのレンダリングが失敗します。

If an error occurs while rendering those components, React won't have any meaningful HTML to send to the client.

クライアントにHTMLが届かないため、ErrorBoundaryが使われる機会自体がありません。

Suspenseありの場合

<ErrorBoundary fallback={<p>チャートを読み込めませんでした</p>}>
  <Suspense fallback={null}>
    <AuthorChart slug={slug} />   {/* ← バウンダリ内 */}
  </Suspense>
</ErrorBoundary>

Suspenseで囲むと、未解決のPromiseがthrowされてもSuspenseがそれを受け取り、fallbackをHTMLに含めます。シェル部分は正常にHTMLが生成・送信されます。

つまりSuspenseは、非同期処理の影響範囲をシェルから分離する境界としての役割を果たしています。

エラーがクライアントのErrorBoundaryに届くまで

HTMLが正常にクライアントに届いた後、エラーはどのようにしてErrorBoundaryまで届くのでしょうか。

Suspenseの公式ドキュメントには、サーバーでエラーが発生した後のクライアント側の挙動が記載されています(Suspense - Providing a fallback for server errors and client-only content)。

If a component throws an error on the server, React will not abort the server render. Instead, it will find the closest <Suspense> component above it and include its fallback (such as a spinner) into the generated server HTML. The user will see a spinner at first.

On the client, React will attempt to render the same component again. If it errors on the client too, React will throw the error and display the closest Error Boundary. However, if it does not error on the client, React will not display the error to the user since the content was eventually displayed successfully.

ただし、上記の「クライアント側で再レンダリング」はクライアントで実行可能なコンポーネントが前提です。サーバーコンポーネントはクライアントで再実行されません。

サーバーコンポーネントの場合、Reactはエラー発生時にRSC Payloadへエラー情報を書き込みます(ReactFlightServer.jsemitErrorChunk)。クライアントがこのエラー情報を受信すると、RSC Payload内の該当チャンクがエラー状態(rejected)になります。Reactがこのチャンクを読み取ると、今度はPromiseではなくErrorオブジェクトがthrowされます。先述の通りErrorはErrorBoundaryの守備範囲なので、ErrorBoundaryがキャッチしてfallbackを表示します。

まとめると、Suspenseありの場合は以下の2段階で処理されています。

  1. サーバー側(renderToPipeableStream: 未解決のPromiseをSuspenseが受け取り、fallback HTMLを出力。HTMLは正常にクライアントへ送信される
  2. クライアント側: RSC Payloadのエラー情報に基づきErrorがthrowされ、ErrorBoundaryがキャッチ

補足: 同期サーバーコンポーネントの場合

ここまでの説明はasync付きのサーバーコンポーネントについてのものです。では、asyncではない同期サーバーコンポーネントがthrowした場合はどうなるでしょうか?

// 同期サーバーコンポーネント
function SyncChart() {
  throw new Error('error')
  return <div>...</div>
}

<ErrorBoundary fallback={<p>エラー</p>}>
  <SyncChart />   {/* Suspenseなし */}
</ErrorBoundary>

実はこの場合、SuspenseがなくてもErrorBoundaryがエラーをキャッチできます。

同期サーバーコンポーネントがthrowした場合、エラーは即座にRSC Payloadに記録されます。非同期の待機が発生しないため、データは最初からエラー状態です。Reactがこのデータを読み取るとErrorオブジェクトが同期的にthrowされます。これは通常のレンダリングエラーと同じ扱いになるため、ErrorBoundaryのgetDerivedStateFromErrorがキャッチできます。

ここまでの内容をまとめると、ポイントとなるのはthrowされるものの型です。

サーバーコンポーネントの種類 throwされるもの 処理する側 Suspenseが必要か
async(未解決の段階) Promise Suspense 必要
async(エラー確定後、クライアント側) Error ErrorBoundary -
同期 Error ErrorBoundary 不要

async付きの場合、renderToPipeableStreamがHTMLを生成する時点ではPromiseがthrowされるためSuspenseが必須です。SuspenseによりHTMLが正常に送信された後、クライアント側でErrorがthrowされてErrorBoundaryがキャッチする、という2段階の流れになっています。

まとめ

全体の流れをまとめます。

Suspenseなし:

サーバー: 未解決のPromiseがthrow → Suspenseがないため処理できない → HTML生成失敗
→ クライアントにHTMLが届かない → ErrorBoundaryが使用される機会がない

Suspenseあり:

サーバー: 未解決のPromiseがthrow → Suspenseがfallback HTMLを出力(HTMLは正常に送信)
クライアント: RSC Payloadのエラー情報 → Errorがthrow → ErrorBoundaryがキャッチ

参照

React公式ドキュメント

Next.jsドキュメント

React内部実装・コアチームの議論

この記事をシェアする

FacebookHatena blogX

関連記事