🍃

App Routerのデータフェッチコロケーションの学びとGraphQL併用で思ったこと

2024/09/14に公開

はじめに

フロントエンド開発において、データフェッチはパフォーマンスと開発体験に大きく影響してきます。

私自身、データフェッチのパフォーマンスと開発体験を両方を追うという観点で GraphQL のフラグメントコロケーションがとても良いと感じています。実際に業務で扱っている Next.js App Router のプロジェクトでも GraphQL を採用しています。

しかし、「Next.js の考え方」を読んだ際に Next.js の fetch でコロケーションをすることで、コロケーションの開発体験を活かしつつ、Next.js のパフォーマンスを最大化できる可能性があることに気づきました。

https://zenn.dev/akfm/books/nextjs-basic-principle/viewer/part_1_colocation

この気づきや感じたことを まとめていきます。 (全然まとまっていません 🙇)

理解できていない部分もあるので、認識の間違いや別の意見などありましたら、コメント頂けますと幸いです。

フェッチ戦略と GraphQL

Root フェッチと Leaf フェッチのトレードオフ

データフェッチの戦略を考える上で

  • Root フェッチ
    ルートコンポーネント(根)でデータをフェッチし、それを子コンポーネントに渡す方法
  • Leaf フェッチ
    データを必要とするコンポーネント(葉)で直接データをフェッチする方法

の 2 つのアプローチがあります。

Root フェッチでは、1 回のリクエストで必要なデータをすべて取得できるためネットワークリクエストの数を最小限に抑えられるというメリットがありつつも、Props のバケツリレーが起こり、変更の影響範囲が大きくなってしまうというデメリットがあります。

反対に Leaf フェッチはデータがコンポーネントごとに閉じているので、他のコンポーネントに依存しないというメリットがありつつも、それぞれのコンポーネントごとにリクエストをすることによるパフォーマンス面でのデメリットがあります。

アプローチ メリット デメリット
Root フェッチ リクエストの数を最小限に抑えられる 変更の影響範囲が大きい
Leaf フェッチ 変更の影響範囲が限定的 複数のリクエストが発生する

GraphQL フラグメントコロケーション

Root フェッチ、Leaf フェッチの両者のメリット・デメリットである開発体験とパフォーマンスのいいところどりするのが、GraphQL フラグメントコロケーションだと思っており、私自身もとても好きな手法です。

https://speakerdeck.com/quramy/fragment-composition-of-graphql
https://zenn.dev/gemcook/articles/2c8796761f2d02

一方で GraphQL は RSC や AppRouter では不要、困難、意味がないという意見もあります。

そこで自分が思った(理解できた)GraphQL と App Router の併用が不要であると考えられる点は、

  • RSC と GraphQL はともにウォーターフォール問題を解決をする点で旨味が重複していること
  • GraphQL を使うと SuspenseError Boundary といった React の機能を活かせなくなること

の 2 点です。

そのため Next.js のfetchAPI を使うことで、フェッチをコロケーションをすることで、コロケーションの開発体験を活かしつつ、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)

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)

post-header.tsx
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>
  );
};
post-content.tsx
const PostContent = ({ content }: PostContentProps) => {
  return (
    <section>
      <p>{content}</p>
    </section>
  );
};
post-footer.tsx
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 フェッチだと例えば「PostFooterlike(いいね数)を非表示にしよう」となったときに、親である page にも修正の影響が及んでしまいます。

結果コンポーネント同士が密結合になり、保守性が悪くなってしまいます。

Leaf フェッチ例

続いて Leaf フェッチする例を見ていきます。

親の page コンポーネントではデータフェッチは行わず、各子コンポーネントでgetPost関数を呼び出すことでデータフェッチしています。

またgetPost関数はfetcher.tsという別ファイルに切り出しました。

Fetcher

fetcher.ts
export const getPost = async (): Promise<Post> => {
  console.log("🍃 : Leafフェッチ");
  const res = await fetch("http://localhost:3000/api/post");
  return res.json();
};

親コンポーネント (page.tsx)

page.tsx
const Page = async () => {
  // ① 親ではフェッチしない
  return (
    <article>
      <PostHeader />
      <PostContent />
      <PostFooter />
    </article>
  );
};

子コンポーネント (PostHeader, PostContent, PostFooter)

post-header.tsx
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>
  );
};
post-content.tsx
export const PostContent = async () => {
  // ② 子でそれぞれフェッチする
  const post = await getPost();
  const { content } = post;
  return (
    <section>
      <p>{content}</p>
    </section>
  );
};
post-footer.tsx
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