App Routerのデータフェッチコロケーションの学びとGraphQL併用で思ったこと
はじめに
フロントエンド開発において、データフェッチはパフォーマンスと開発体験に大きく影響してきます。
私自身、データフェッチのパフォーマンスと開発体験を両方を追うという観点で GraphQL のフラグメントコロケーションがとても良いと感じています。実際に業務で扱っている Next.js App Router のプロジェクトでも GraphQL を採用しています。
しかし、「Next.js の考え方」を読んだ際に Next.js の fetch でコロケーションをすることで、コロケーションの開発体験を活かしつつ、Next.js のパフォーマンスを最大化できる可能性があることに気づきました。
この気づきや感じたことを まとめていきます。 (全然まとまっていません 🙇)
理解できていない部分もあるので、認識の間違いや別の意見などありましたら、コメント頂けますと幸いです。
フェッチ戦略と GraphQL
Root フェッチと Leaf フェッチのトレードオフ
データフェッチの戦略を考える上で
-
Root フェッチ:
ルートコンポーネント(根)でデータをフェッチし、それを子コンポーネントに渡す方法 -
Leaf フェッチ:
データを必要とするコンポーネント(葉)で直接データをフェッチする方法
の 2 つのアプローチがあります。
Root フェッチでは、1 回のリクエストで必要なデータをすべて取得できるためネットワークリクエストの数を最小限に抑えられるというメリットがありつつも、Props のバケツリレーが起こり、変更の影響範囲が大きくなってしまうというデメリットがあります。
反対に Leaf フェッチはデータがコンポーネントごとに閉じているので、他のコンポーネントに依存しないというメリットがありつつも、それぞれのコンポーネントごとにリクエストをすることによるパフォーマンス面でのデメリットがあります。
アプローチ | メリット | デメリット |
---|---|---|
Root フェッチ | リクエストの数を最小限に抑えられる | 変更の影響範囲が大きい |
Leaf フェッチ | 変更の影響範囲が限定的 | 複数のリクエストが発生する |
GraphQL フラグメントコロケーション
Root フェッチ、Leaf フェッチの両者のメリット・デメリットである開発体験とパフォーマンスのいいところどりするのが、GraphQL フラグメントコロケーションだと思っており、私自身もとても好きな手法です。
一方で GraphQL は RSC や AppRouter では不要、困難、意味がないという意見もあります。
そこで自分が思った(理解できた)GraphQL と App Router の併用が不要であると考えられる点は、
- RSC と GraphQL はともにウォーターフォール問題を解決をする点で旨味が重複していること
- GraphQL を使うと Suspense や Error Boundary といった React の機能を活かせなくなること
の 2 点です。
そのため Next.js のfetch
API を使うことで、フェッチをコロケーションをすることで、コロケーションの開発体験を活かしつつ、AppRouter、RSC のパフォーマンスを最大化することができます。
App Router では、Leaf フェッチをしよう
Leaf フェッチがいい理由
Leaf フェッチのアプローチが良い理由は
- Request Memorizationでレスポンスをキャッシュできる
- Suspense や Error Boundary といった React の機能を活かせる
といったところが考えられます。
Request Memorization でレスポンスをキャッシュできる
Next.js AppRouter は Web 標準の fetchAPI を拡張しています。Next.js の fetchAPI は、Request Momorization という機能でリクエストに対するレスポンスをキャッシュし、同じリクエストが繰り返される場合にはキャッシュされたデータを利用することで、パフォーマンスを向上させます。
このキャッシュにより、Leaf フェッチを行って複数箇所で同じデータフェッチを行うことによるパフォーマンスの問題がなくなり、効率的なデータ取得が可能になります。
React の機能を活かせる
さらに、Leaf フェッチのもう一つの大きな利点は、Suspense や Error Boundary といった React の機能を活かせることです。
Suspense は、React18 から正式に導入された機能で、非同期データの読み込み中にローディング状態を管理し、コンポーネントの表示をスムーズにするために使用されます。Next.js では、Suspense を使うことで、HTML を小さな塊に分解し、その塊をクライアントに順次送信できる Streaming SSR を実現できます。
Error Boundary は、子コンポーネントで発生した JavaScript エラーを検知し、そのエラー部分を置き換えるようなエラーメッセージを表示する機能です。Next.js のerror.tsx
も Error Boundary を活用した機能の 1 つで、error.tsx
よりももっと細かなコンポーネント単位でエラーハンドリングすることができます。
このように App Router での Leaf フェッチは、パフォーマンスの向上と優れたユーザー体験の提供を両立させるための効果的なアプローチだと考えられます。
コード例
では実際に簡単なブログサイトを例にして、コロケーションせずに Root フェッチして Props をバケツリレーする場合と、コロケーションして Leaf フェッチするコードを見ていきます。
page.tsx(Root)の中に PostHeader
,PostContent
,PostFooter
の 3 つの子コンポーネントがある というようなブログサイトをイメージしていただければと思います。
また Post データをフェッチする API は Route Handlers でダミーの Post データを返すものを使用しています。Route Handlers で作った Post の API を getPost
関数でフェッチしています。
Route Handlers のコード
Router Handlers の中では、リクエスト、キャッシュの有無を確かめるために、💥: Fetch
とコンソールログを出すようにしています。
export async function GET() {
console.log("💥 : リクエスト");
return Response.json(post);
}
const post: Post = {
id: "1",
title: "今日は新しいカフェに行ってきました!",
content: `今日は友達と一緒に新しくオープンしたカフェに行ってきました。おしゃれな内装と落ち着いた雰囲気で、とてもリラックスできました。特に美味しかったのは、季節限定の抹茶ラテです。スイーツも豊富で、次回はぜひケーキも試してみたいと思います。東京にはたくさんの素敵なカフェがあって、毎回新しい発見がありますね。`,
author: "山田太郎",
createdAt: "2024-09-12T10:00:00Z",
likes: 12,
retweets: 3,
profileImage: "https://dummyjson.com/icon/taro/128",
tags: ["カフェ", "東京"],
};
Root フェッチ例
まず Root フェッチする例を見ていきます。何も考えずに実装すると以下のようになると思います。
Page コンポーネントでPost
(投稿データ)をフェッチして、フェッチした投稿データを子コンポーネントに渡すという実装です。
親コンポーネント (page.tsx)
const getPost = async (): Promise<Post> => {
console.log("🪵 : Rootフェッチ");
const res = await fetch("http://localhost:3000/api/post");
return res.json();
};
const Page = async () => {
const post = await getPost(); // ① 親で1回フェッチして
return (
<article>
{/* ② フェッチしたデータを子に渡す */}
<PostHeader title={post.title} author={post.author} profileImage={post.profileImage} />
<PostContent content={post.content} />
<PostFooter likes={post.likes} retweets={post.retweets} tags={post.tags} />
</article>
);
};
export default Page;
子コンポーネント (PostHeader, PostContent, PostFooter)
const PostHeader = ({ title, author, profileImage }: PostHeaderProps) => {
return (
<header>
<Image
src={profileImage}
alt=""
width={40}
height={40}
/>
<div>
<h1>{title}</h1>
<p>by {author}</p>
</div>
</header>
);
};
const PostContent = ({ content }: PostContentProps) => {
return (
<section>
<p>{content}</p>
</section>
);
};
const PostFooter = ({ likes, retweets, tags }: PostFooterProps) => {
return (
<footer>
<div>
<span>いいね: {likes}</span>
<span className="ml-4">リツイート: {retweets}</span>
</div>
<div>
{tags && tags.length > 0 && (
<ul>
{tags.map((tag, index) => (
<li key={index}>
{tag}
</li>
))}
</ul>
)}
</div>
</footer>
);
};
ローカル環境で実行したところターミナルには、🪵: Rootフェッチ
と💥: リクエスト
が 1 回ずつ出力されたことから、1 回 getPosts
関数が実行されて、1 回 API にリクエストされたことがわかります。特に変わったことはありません。
一方、開発体験の観点で Root フェッチだと例えば「PostFooter
でlike
(いいね数)を非表示にしよう」となったときに、親である page にも修正の影響が及んでしまいます。
結果コンポーネント同士が密結合になり、保守性が悪くなってしまいます。
Leaf フェッチ例
続いて Leaf フェッチする例を見ていきます。
親の page コンポーネントではデータフェッチは行わず、各子コンポーネントでgetPost
関数を呼び出すことでデータフェッチしています。
またgetPost
関数はfetcher.ts
という別ファイルに切り出しました。
Fetcher
export const getPost = async (): Promise<Post> => {
console.log("🍃 : Leafフェッチ");
const res = await fetch("http://localhost:3000/api/post");
return res.json();
};
親コンポーネント (page.tsx)
const Page = async () => {
// ① 親ではフェッチしない
return (
<article>
<PostHeader />
<PostContent />
<PostFooter />
</article>
);
};
子コンポーネント (PostHeader, PostContent, PostFooter)
export const PostHeader = async () => {
// ② 子でそれぞれフェッチする
const post = await getPost();
const { profileImage, author, title } = post;
return (
<header>
<Image src={profileImage} alt="" width={40} height={40} />
<div>
<h1>{title}</h1>
<p>by {author}</p>
</div>
</header>
);
};
export const PostContent = async () => {
// ② 子でそれぞれフェッチする
const post = await getPost();
const { content } = post;
return (
<section>
<p>{content}</p>
</section>
);
};
export const PostFooter = async () => {
// ② 子でそれぞれフェッチする
const post = await getPost();
const { likes, retweets, tags } = post;
return (
<footer>
<div>
<span>いいね: {likes}</span>
<span>リツイート: {retweets}</span>
</div>
<div>
{tags && tags.length > 0 && (
<ul>
{tags.map((tag, index) => (
<li key={index}>{tag}</li>
))}
</ul>
)}
</div>
</footer>
);
};
ローカル環境で実行したところターミナルには、🍃 : Leafフェッチ
が 3 回と💥: リクエスト
が 1 回出力されました。このことから各子コンポーネントでgetPosts
関数が実行されても、実際に 1 回しか API リクエストされていないことがわかります。
また「PostFooter
でいいね数を消したい」、「PostHeader
に投稿日を表示したい」などの修正にも、コンポーネント内でデータをフェッチしているので、他のコンポーネントに影響を及ばさず変更することができます。
パフォーマンスの観点でも、開発体験の観点でも良さそうです。
GraphQL はもういらないのか
App Router で Leaf フェッチすることによるメリットを見てきましたが、GraphQL 不要論についても思ったことを書いていきます。
私は「GraphQL いらない」を言い切るにはまだ早いと思っています。
(それを言ったら元も子もないですが)App Router や RSC は React の技術なので、React を採用していない場合に関しては GraphQL の良さを享受できます。
また App Router・RSC と GraphQL を併用する場合に関しても
- スキーマ駆動開発によって、フロントエンドとバックエンドのコミュニケーションコストを減らせること
- スキーマからフロントエンドで使う型を自動生成できること
- オーバーフェッチ、アンダーフェッチを防げること
など RSC だけでは得られない GraphQL のメリットはあります。
作るアプリケーションの要件として、ページ全体が軽量でコンテンツが少ないアプリケーションの場合ストリーミングできるメリットは弱くなるので、型安全という GraphQL のメリットをとっても良いんじゃないかと思っています。
とはいえ、私がいまイチから技術選定するときに GraphQL と App Router・RSC を併用するかどうかで言うと、「しない」と思います。
さいごに
App Router・RSC と GraphQL は相反する思想がある中で、メリットが重複してしまっています。結果、App Router・RSC 独自のメリットと GraphQL 独自のメリットを比較したときに、App Router・RSC が選ばれるというのが、現状の考え方なのかなと思いました。ただどちらのメリットも理解することで、その時々で最適な技術選定ができると思いました。
補足
GraphQL でも@defer
ディレクティブを使ってレスポンスを分割して段階的に返すことができそう
Discussion