2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React Server ComponentsでのPromiseの最適な扱い方

Last updated at Posted at 2025-02-10

React 18以降では、React Server Components (RSC) と呼ばれる仕組みが導入され、サーバー側でReactコンポーネントをレンダリングすることができます。RSCでデータを取得するとき、初心者の方がつまずきやすいのがPromiseの扱いです。特に、Promiseを単純にawaitする方法と、<Suspense>コンポーネント + use()フックを使う方法の違いは重要なポイントです。本記事では、なぜRSCではPromiseをawaitせずにSuspenseとuse()を使うべきなのか、その理由やメリット・デメリットをわかりやすく解説します。

なぜRSCでawaitではなくSuspense+use()を使うのか?

結論から言えば、React Server ComponentsではPromiseを直接awaitせずに、<Suspense>とReactのuse()フックを組み合わせて扱うのがベストプラクティスです。その最大の理由は、サーバーサイドのストリーミングレンダリング(Streaming SSR)を活用できるからです。 (reactjs - Why use Suspense in React Server Components - Stack Overflow)

  • <Suspense>を使うと、サーバーはまずページ全体の骨組み(シェル)をレンダリングし、データが必要な部分を「穴あき」の状態にしておくことができます (reactjs - Why use Suspense in React Server Components - Stack Overflow)。その穴の部分(非同期コンポーネント部分)には、一時的にフォールバックUI(例えば「Loading...」のようなローディング表示)が表示されます。
  • サーバーはシェル部分のHTMLを先にクライアントへストリーミング送信し、その後バックエンドでデータ取得(Promiseの解決)を続けます。データが取得でき次第、その部分のコンテンツをストリーム経由で追送信し、フォールバックUIと置き換えてブラウザに表示させます。
  • このように段階的にHTMLを送ることで、ユーザーはページの一部をすぐに目にでき、残りの部分もデータが揃い次第シームレスに表示されます。結果として初期表示が早くなり、ユーザーエクスペリエンスが向上します。

一方、Promiseを普通にawaitしてしまうと何が起こるでしょうか? サーバーはPromiseが解決されるまでレンダリング処理を一時停止し、HTMLの生成を待ちます。その間、ユーザーには何も表示されません。つまり、awaitを使うとそのコンポーネント以下のツリー全体のレンダリングがブロックされてしまうのです。複数のデータ取得が連鎖すると「waterfall(滝のように順次実行されるため遅延が蓄積すること)」が発生し、ページ全体の表示が大きく遅れてしまいます。

まとめると、Suspenseを使えば非同期処理中でも他の部分の表示・操作をブロックしないため、RSCではSuspenseを活用した方が良いのです。

awaitした場合のデメリット

RSCでPromiseをawaitしてしまうと、以下のようなデメリットがあります。

  • ページ表示の遅延: awaitは同期的に処理を待つため、そのコンポーネントのレンダリング結果が出るまで他の部分も含めて何も送信されません。ユーザーは白紙のページや固まった状態を長く待つことになります。特に複数のデータを順番にawaitすると待ち時間がどんどん長くなります(いわゆる「データ取得の滝問題」)。
  • ストリーミング不可: サーバー側でPromiseを解決 (await) してしまうと、クライアントには「完成済みの結果」しか送れません。ストリーミングSSRの恩恵(部分的な先送り表示)が受けられず、「データが全部揃ってからまとめて表示」という従来型の挙動になります。
  • UXの低下: 画面全体がロード完了までローディング状態になるため、ユーザーはインタラクションできずストレスを感じます。場合によってはユーザーがページが動いていないと感じて離脱する可能性もあります。

例えば、次のようなコードを考えます。

// ❌ 良くない例:awaitでデータ取得をブロックしてしまう
export default async function Page() {
  // データを取得(この間、このコンポーネント以下のレンダリングは停止)
  const res = await fetch('https://api.example.com/data');
  const data = await res.json();

  return (
    <main>
      <h1>ダッシュボード</h1>
      {/* データ取得完了後でないと以下はレンダリングされない */}
      <div>データ件数: {data.count}</div>
    </main>
  );
}

上記ではfetchでデータを取得し終わるまで、<h1>ダッシュボード</h1>すらユーザーに表示されません。サーバーはデータが揃うまでHTMLを送出しないため、ユーザーは白い画面のまま待つことになります。

Suspense+use()を使うメリット

<Suspense>コンポーネントとReactのuse()フックを組み合わせると、RSCでのデータ取得は驚くほどスムーズになります。主なメリットは次のとおりです。

  • ストリーミングによる高速表示: 前述の通り、Suspenseは非同期コンポーネントの読み込みを待つ間フォールバックUI(fallbackプロップで指定したコンポーネント)を表示できます。そのため、ページの他の部分はすぐ表示して、重いデータ部分だけ遅れて差し込むということが可能です (reactjs - Why use Suspense in React Server Components - Stack Overflow)。ユーザーは瞬時にページの骨組みを見られ、後からデータ部分が埋まっていくため、体感速度が向上します。
  • レンダリングブロックの範囲縮小: Suspenseで囲まれた部分以外はブロックされずレンダリングされます。例えばページ全体ではなく一部のウィジェットだけデータ取得が遅い場合、そのウィジェット部分だけをSuspenseで囲めば、他の部分は遅延なく表示・操作できます。
  • コードの見通し改善: データ取得のために複雑なローディング状態管理や副作用フック (useEffect など) を書かずに済みます。サーバーコンポーネント内で自然にasync/awaitを書く代わりに、use()を使ってPromiseの結果を直接参照できるため、より宣言的でシンプルなコードになります。エラーハンドリングも、Promiseがrejectした場合は自動的にエラーとして扱われ、近くのError Boundaryで捕捉できます(エラーハンドリングの詳細は本記事の範囲外ですが、Suspenseと組み合わせるとエレガントに処理できます)。
  • クライアントコンポーネントとの連携: RSCは基本的にサーバー上でレンダリングされますが、場合によっては取得したデータをクライアントコンポーネントに渡して使いたいこともあります。use()フックを使えば、サーバーからクライアントコンポーネントにPromise自体を渡し、クライアント側でそれを解決して表示することも可能です。Reactは未解決のPromiseを検知すると内部で一時停止 (throwによるサスペンド) し、適切に再開・表示してくれます。

では、先ほどのコード例をSuspenseとuse()を用いて改善してみましょう。

import { Suspense, use } from 'react';

// ✅ 良い例:Suspenseで非同期処理中でもページ全体をブロックしない
export default function Page() {
  // データ取得を開始(Promiseを得る)。ここではまだawaitしない!
  const dataPromise = fetch('https://api.example.com/data').then(res => res.json());

  return (
    <main>
      <h1>ダッシュボード</h1>
      {/* Suspenseでラップし、フォールバックを指定 */}
      <Suspense fallback={<p>Loading...</p>}>
        {/* Promiseを子コンポーネントに渡す */}
        <DataWidget dataPromise={dataPromise} />
      </Suspense>
    </main>
  );
}

// DataWidgetはPromiseを受け取り、その完了を待ってデータを表示するコンポーネント
function DataWidget({ dataPromise }) {
  // use()でPromiseの中身を取得(Promiseが未完了ならこのコンポーネントはサスペンドする)
  const data = use(dataPromise);
  return <div>データ件数: {data.count}</div>;
}

上記のコードでは、PageコンポーネントでdataPromiseを取得していますが**awaitしていない点**に注目してください。Pageは同期的にJSXを返すので、<h1>ダッシュボード</h1>など静的な部分は即座にレンダリングされます。一方で、<Suspense>でラップした<DataWidget>内ではuse(dataPromise)が呼ばれます。dataPromiseが解決していない場合、use()は内部的にPromiseをthrowし、このコンポーネントのレンダリングを一時停止させます。ReactはそのthrowされたPromiseをキャッチし、親のSuspenseがある場合は自動的にフォールバックUI (<p>Loading...</p>) を表示します。データ取得完了後、dataPromiseが解決されるとDataWidgetが再レンダリングされ、本物のデータ(件数)が表示されます。

この仕組みによって、ユーザーはページを開いてすぐ「ダッシュボード」という見出しと"Loading..."インジケーターを見ることができ、バックエンドでデータ取得が終わった瞬間に件数がパッと表示されるわけです。重い処理でなければフォールバックは一瞬で消え去るでしょうし、仮に数秒かかる場合でもユーザーには途中経過が見えるため安心感があります。

シンプルな例で理解する

上記の例を図式化してみましょう(※コードは擬似的な流れを示すものです):

await vs Suspense+use の比較

awaitを使った場合(従来の方法)

サーバーはデータ取得が終わるまでHTMLを送りません。ユーザーの画面は3秒間真っ白です。その後、まとめて完成したHTMLが届き、一度に表示されます。

Suspense+useを使った場合(Streaming SSR)

サーバーは最初に静的なHTMLとローディング表示を送信し、その後データ取得が完了次第、動的なコンテンツを追加で送信します。ユーザーは最初から何らかの表示を見ることができ、完了したデータは自動的に反映されます。

このように、まずページの土台とフォールバックUIが即座に表示され、その後足りない部分だけ後から埋まる形になります。ユーザーから見れば、ページは瞬時に表示されて徐々に内容がリッチになっていくように見えるでしょう。特にネットワークが遅い場合でも、「何も起こらない…」という状態を防げるのは大きな利点です。

「Promiseをawaitしないまま使うなんて大丈夫なの?」と思うかもしれません。しかし、React 18+ではこの**「PromiseをそのままUIとして扱う」発想がSuspense機能によって公式にサポートされています。use()フックのおかげで、上記の例のように本当にawaitせずにPromiseを使える**のです。Reactが裏でちゃんと面倒を見てくれるので心配はいりません。

補足: use()フックはReact 18で登場した実験的な機能でしたが、現在では公式にRSCなどで使用できます。use(promise)とすると、ReactはPromiseの状態に応じて自動で処理を切り替えてくれます(解決済みなら値を返し、未解決なら一時停止し、エラーなら投げ直す)。

ベストプラクティスと設計のポイント

最後に、React Server Componentsにおける非同期処理のベストプラクティスをまとめます。

  • 可能な限りSuspenseを活用する: 非同期データ取得が絡むコンポーネントは、積極的に<Suspense>でラップしましょう。こうすることで、データロード中でも他の部分の表示を妨げず、ユーザーに素早いフィードバックを返せます。特に、大きなページ全体を一度にレンダリングするより、部分ごとにSuspense境界を設けた方がインタラクティブ性が高まります。
  • Promiseは先に作成し、あとでuse()する: データ取得のリクエスト自体はできるだけ早い段階(上位コンポーネント)で開始し、そのPromiseを子コンポーネントに渡してuse()で読むようにしましょう。こうすることでデータ取得が並行して行われ、無駄な待ち時間が減ります。複数のデータ源がある場合も、一つずつ順番にawaitするのではなく、Promiseを複数生成してからPromise.all()するか、それぞれ別々のSuspense境界で囲むことで並行処理できます。
  • フォールバックUIを適切に用意する: Suspenseのfallbackにはユーザーが違和感を持たないロード中の表示を入れましょう。単純な"Loading..."テキストやスピナーでも構いませんが、よりコンテキストに合ったプレースホルダー(例えばローディング用のスケルトンUI)を用意するとユーザー体験が向上します。フォールバックはできるだけ早く表示されるため、ユーザーにはページが動いていることが伝わります
  • エラーハンドリングも忘れずに: Promiseが失敗する場合もあります。Suspenseと組み合わせる際は、上位にError Boundary(<ErrorBoundary>コンポーネント)を設置しておくと安心です。use()で投げられたエラーはそこでキャッチできるので、エラー時にフォールバックとしてエラーメッセージを表示する、といった対応が可能です。
  • クライアントコンポーネントではデータ取得しない: RSCの利点を活かすために、データフェッチは基本的にサーバーコンポーネント側で行いましょう。クライアントコンポーネント("use client"指定したコンポーネント)内では直接非同期処理を行えず、誤ってasync/awaitを書くとエラーになります。どうしてもクライアント側でデータ取得が必要な場合は、React QueryやSWRといったライブラリの利用を検討してください。ただし初学者のうちは、まずRSC + Suspenseのパターンに慣れることをおすすめします。

以上、React Server ComponentsにおけるPromiseの扱い方について、awaitSuspense+use()の比較からベストプラクティスまで解説しました。最初は少し不思議に思えるかもしれませんが、実際に手を動かしてみるとSuspenseを使った非同期処理のシンプルさと強力さに驚くでしょう。ぜひ自身のプロジェクトでも試してみて、快適なユーザー体験を提供できるRSCの非同期処理をマスターしてください。

【参考資料】

  • React公式: Suspenseとuse()フックの解説
  • Next.js公式: データフェッチングパターンとStreaming SSR
  • Ed Spencer: React Server ComponentsでのStreamingに関するブログ記事
2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?