🌊

コンポーネントの「ウォーターフォール問題」をどう解決するか by React開発者 Dan Abramov氏

に公開

Webアプリケーションのパフォーマンス、特に「データフェッチ」のアーキテクチャは毎回悩ましい箇所であり、今もなお開発者間で様々な意見が飛び交う話題かと思います。

本記事は、React開発者であるDan Abramov氏による記事 One Roundtrip Per Navigation の内容を元に、Web開発におけるデータ取得パターンの歴史的変遷と、なぜReact Server Componentsがその答えの一つとなり得るのかを解説します。

序論:理想的な画面遷移とは何か?

ユーザーがリンクをクリックして次のページに遷移するとき、ブラウザは何回リクエストを飛ばすべきでしょうか?

まあ普通に考えて「1回」ですよね。
HTMLコンテンツをリクエストし、それを表示する。もちろん画像やスクリプトなどのサブリソースはありますが、ページを描画するための主要なデータ取得は、理想的には1回のラウンドトリップ(往復)で解決されるべきです。

しかし、リッチなインタラクションを持つ現代のWebアプリにおいて、データの取得はどうなっているでしょうか?

1. HTMLアプリの時代:本来の「1往復」

かつて、サーバー中心のHTMLアプリ(RailsやDjangoで作られた従来のWebサイト)では、サーバーは単なるAPIサーバーではなく、HTMLを返す存在でした。

みなさんご存知の通り、ユーザーがリンクをクリックするとサーバーはHTMLを返します。必要なデータはすべてそのHTMLの中に埋め込まれています。つまり、データ取得は常に1回のラウンドトリップで完結していました

2. クライアントサイドへの移行と「ウォーターフォール」の発生

しかし、UIロジックをクライアント(ブラウザ)に移行するにつれてJSON API(RESTなど)が使用され始めました。「投稿を表示したいなら投稿リソースをフェッチ」「コメントを表示したいならコメントリソースをフェッチ」というような感じですね。

これにより、1回のクリックで複数のフェッチが発生するようになりました。
最悪の場合、データ取得が直列に連鎖する「ウォーターフォール」問題が発生します。

// クライアントサイドでの典型的なウォーターフォール
const post = await fetch(`/api/posts/${postId}`).then(r => r.json());
// 投稿データの取得を待ってからでないと、次の処理ができない場合がある
const comments = await fetch(`/api/posts/${postId}/comments`).then(r => r.json());

サーバー側であれば、データソース(DB)の近くで効率的に解決できた問題が、クライアントからブラックボックス化されたサーバーAPIを叩く構成になったことで、非効率な通信が増えてしまったのです。

3. 「コロケーション」と「効率」のジレンマ

開発者としてはデータをロードするロジックを、そのデータを使用するコンポーネントの近くに置きたいと考えます(コロケーション)。UIとデータ要件は密結合しているからです。

コンポーネント内フェッチ (Fetch-on-Render)

ReactのuseEffectなどでコンポーネント内にデータ取得を書くアプローチは、コロケーションの観点からは優れています。しかし、これは「リクエストのウォーターフォール」を悪化させます。

親コンポーネントがレンダリングされ、データを取得し、子コンポーネントが表示され、さらにその子がデータを取得する……という連鎖が起きます。コードベース全体にデータ取得ロジックが分散するため、パフォーマンスのボトルネックを特定して修正するのも困難になります。

React Queryなどのアプローチ

useQueryなどのライブラリはデータ取得を構造的なものにしてくれますが、本質的な「N個のアイテムに対してN回のリクエスト」「親子関係によるウォーターフォール」という問題自体を解決するわけではありません。

また、クライアントサイドキャッシュは「戻るボタン」やタブ切り替えには有効ですが、ユーザーがリンクをクリックした際の「新しいコンテンツを見たい」という期待(Freshness) には応えられません。古いキャッシュを一瞬見せてから更新する挙動は、必ずしも良いUXとは言えません。

4. 解決策の模索:ローダー(Loaders)

では、コロケーション(コンポーネントごとの記述)を諦め、ルートごとにデータを一括で取得する「ローダー」というアプローチはどうでしょうか?

クライアント・ローダー (Client Loaders)

React RouterのclientLoaderのように、ルート遷移前に必要な全データを並列で取得します。

async function clientLoader({ params }) {
  const { postId } = params;
  // 並列取得によりウォーターフォールを防ぐ
  const [post, comments] = await Promise.all([
    fetch(`/api/posts/${postId}`).then(r => r.json()),
    fetch(`/api/posts/${postId}/comments`).then(r => r.json())
  ]);
  return { post, comments };
}

これによりウォーターフォールは回避できますが、コンポーネントとそのデータ要件が切り離されるという欠点があります。ルートレベルのコードが、配下の全コンポーネントが必要とするデータを知っている必要があります。

サーバー・ローダー (Server Loaders)

ローダーをサーバーサイド(RemixのloaderやNext.jsのgetServerSidePropsなど)に移すと、さらに強力になります。DBへ直接アクセスでき、レイテンシを制御し、オーバーフェッチを防げます。
クライアントから見れば、データは再び1回のラウンドトリップで届くようになります。

しかし、依然として「コロケーション」は失われたままです。

5. Server Functions の誤解

そうすると自然に「サーバー・ローダーの効率性を維持しつつ、コロケーションを取り戻せないか?」という考えが出てきます。そこで登場するのが、Server Functions(サーバー関数をクライアントから直接importして呼ぶRPC的な仕組み)です。

// コンポーネントから直接サーバー関数を呼ぶ
import { getPost } from './my-server-functions';

function PostContent({ postId }) {
  // これでコロケーション復活!...に見えるが
  const { data } = useQuery(['post', postId], () => getPost(postId));
   // ...
}

構文上は美しく見えます。しかし、これをコンポーネント内に配置すると、パフォーマンス特性は「コンポーネント内フェッチ(Fetch-on-Render)」に逆戻りします
クライアントがレンダリングの過程で都度サーバー関数を呼び出すため、ネットワークタブには再びリクエストの滝が現れます。Server FunctionsはAPI作成の手間を減らすだけで、データフェッチの構造的な問題を解決するものではありません。

6. 真の解決策:GraphQL Fragments と RSC

ここでDan Abramov氏は、「コロケーション(保守性)」と「効率(1往復)」の両立を実現する数少ない解決策が2つあると説明しています。

アプローチA: GraphQL Fragments

GraphQL(特にRelay的な使い方)は、コンポーネント単位で「フラグメント(必要なデータの定義)」を宣言させます。
コンポーネント自体はデータをフェッチせず、「何が必要か」を宣言するだけです。それらをトップレベルで結合し、1つのクエリとして発行します。

これにより、各コンポーネントにデータ要件をコロケーションしつつ、実行時は常に1回のラウンドトリップでデータを取得できます。

アプローチB: React Server Components (RSC)

Reactチームが出した答えがRSCです。
これは、「サーバー・ローダー」の概念をコンポーネント単位まで分解したものと捉えられます。ただし、クライアントからサーバー・ローダーを呼ぶのではなく、サーバー・ローダーがコンポーネントを返すのです。

合わせてこちらの記事も読むと、より理解が深まります
https://zenn.dev/dragon1208/articles/9ae52d98a8c87b

// サーバー上で実行されるコンポーネント
import { loadPost } from 'my-data-layer';
import { Comments } from './Comments'; // これもサーバーコンポーネントになり得る

async function PostContent({ postId }) {
  // DBから直接データをロード(低レイテンシ)
  const post = await loadPost(postId); 
  
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
      {/* 子コンポーネントもサーバー上でデータをロード可能 */}
      <Comments postId={postId} />
    </article>
  );
}

このモデルでは以下のメリットが得られます:

  1. Server Loadersの効率性: クライアント/サーバー間のウォーターフォールは物理的に発生しません。データはサーバー内で解決され、クライアントにはレンダリング結果(ツリー)がストリーミングされます。
  2. コンポーネントのコロケーション: データ取得ロジックはコンポーネント内部に書かれます。
  3. HTMLアプリのメンタルモデル: クライアントから見れば、遷移は常に「サーバーへの単一リクエスト」で開始されます。

結論

データフェッチの歴史を振り返ると、開発において 「効率なのか」と「コロケーションなのか」 というトレードオフの間を行ったり来たりしてきたのではないでしょうか?

  • HTML: 効率的だが、インタラクティブ性に欠けた。
  • REST/Fetch-on-render: コロケーションは良いが、非効率(ウォーターフォール)。
  • Loaders: 効率的だが、コロケーションを犠牲にした。

そして今、「コロケーション」と「効率」の両方を解決するソリューションとして存在するのが、以下の3つです。

  1. HTMLテンプレート(Astroなどのモダンな実装を含む)
  2. GraphQL(Fragmentsによる合成)
  3. React Server Components (RSC)

RSCが目指しているのは、単なる新しい機能ではなく、Web開発における長年のトレードオフを解消し、かつてのHTMLアプリが持っていた「単純さ」と「パフォーマンス」を、現代のコンポーネントモデルに取り戻すことなのです。

Discussion