コンポーネントの「ウォーターフォール問題」をどう解決するか 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です。
これは、「サーバー・ローダー」の概念をコンポーネント単位まで分解したものと捉えられます。ただし、クライアントからサーバー・ローダーを呼ぶのではなく、サーバー・ローダーがコンポーネントを返すのです。
合わせてこちらの記事も読むと、より理解が深まります
// サーバー上で実行されるコンポーネント
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>
);
}
このモデルでは以下のメリットが得られます:
- Server Loadersの効率性: クライアント/サーバー間のウォーターフォールは物理的に発生しません。データはサーバー内で解決され、クライアントにはレンダリング結果(ツリー)がストリーミングされます。
- コンポーネントのコロケーション: データ取得ロジックはコンポーネント内部に書かれます。
- HTMLアプリのメンタルモデル: クライアントから見れば、遷移は常に「サーバーへの単一リクエスト」で開始されます。
結論
データフェッチの歴史を振り返ると、開発において 「効率なのか」と「コロケーションなのか」 というトレードオフの間を行ったり来たりしてきたのではないでしょうか?
- HTML: 効率的だが、インタラクティブ性に欠けた。
- REST/Fetch-on-render: コロケーションは良いが、非効率(ウォーターフォール)。
- Loaders: 効率的だが、コロケーションを犠牲にした。
そして今、「コロケーション」と「効率」の両方を解決するソリューションとして存在するのが、以下の3つです。
- HTMLテンプレート(Astroなどのモダンな実装を含む)
- GraphQL(Fragmentsによる合成)
- React Server Components (RSC)
RSCが目指しているのは、単なる新しい機能ではなく、Web開発における長年のトレードオフを解消し、かつてのHTMLアプリが持っていた「単純さ」と「パフォーマンス」を、現代のコンポーネントモデルに取り戻すことなのです。
Discussion