🐙

Next.js App Router × Apollo Clientにおける実装方法と内部の仕組み

に公開

最近のプロジェクトでNext.jsのApp Routerへの移行を進めていて、その過程でApp RouterとApollo Clientの連携について色々とキャッチアップする機会がありました。
そこで今回は、App RouterとApollo Clientを組み合わせた場合の実装方法と内部の仕組みについて紹介したいと思います!

記事の中で使用するサンプルコードは下記にあるので、興味があれば見てみてください。

https://github.com/KazukiHayase/app-router-apollo-client-catch-up

App Router + Apollo Clientの実装方法

App RouterとApollo Clientを組み合わせる場合は、@apollo/client-integration-nextjsを使用します。

https://github.com/apollographql/apollo-client-integrations

Server Componentsの場合

Server Components(以下SC)でApollo Clientを使用するために、Apollo ClientのインスタンスをregisterApolloClientで登録します。

import {ApolloClient, HttpLink, InMemoryCache} from "@apollo/client";
import {registerApolloClient} from "@apollo/client-integration-nextjs";

export const {getClient} = registerApolloClient(() => {
  return new ApolloClient({
    cache: new InMemoryCache(),
    link: new HttpLink({
      uri: "http://localhost:3000/api/graphql",
    }),
  });
});

上記で登録したインスタンスを、各コンポーネントで使用することで、SCでQueryを実行することができます。

import gql from "graphql-tag";
import {getClient} from "@/app/ApolloClient";

const userQuery = gql`
  query {
    getUser(id: "1") {
      id
      name
    }
  }
`;

export default async function Page() {
  const {data} = await getClient().query({query: userQuery});

  return <p>data received during Page render: {JSON.stringify(data)}</p>;
}

後ほど説明しますが、getClient内部では、React.cacheが使用されており、同一リクエスト内では同一のインスタンスが使用されるようになっています。
そのため、getClientはコンポーネント内で呼び出す必要があります。

Client Componentsの場合

Client Components(以下CC)の場合は、@apollo/client-integration-nextjsのProviderを使用して、Apollo Clientのインスタンスを登録します。
従来の@apollo/clientApolloProviderでは、すでにインスタンス化されたclientオブジェクトをPropsで受け取りますが、ApolloNextAppProviderではインスタンスを生成するためのmakeClientという関数を受け取ります。

"use client";

import {HttpLink} from "@apollo/client";
import {
  ApolloNextAppProvider,
  ApolloClient,
  InMemoryCache,
} from "@apollo/client-integration-nextjs";

function makeClient() {
  const httpLink = new HttpLink({
    uri: "http://localhost:3000/api/graphql",
  });

  return new ApolloClient({
    cache: new InMemoryCache(),
    link: httpLink,
  });
}

export function ApolloWrapper({children}: React.PropsWithChildren) {
  return (
    <ApolloNextAppProvider makeClient={makeClient}>
      {children}
    </ApolloNextAppProvider>
  );
}

fetchOptionsについて

Next.js App RouterではNext.js独自に拡張されたfetch関数が使用されており、この関数にはキャッシュ制御などのためのオプションを渡すことができます。Apollo Clientでも、この拡張されたfetchオプションを活用することができます。

Apollo Clientのインスタンスを生成する際に、HttpLinkのオプションとしてfetchOptionsを指定することで、すべてのGraphQLリクエストにNext.jsのfetchオプションを適用できます。

export const {getClient} = registerApolloClient(() => {
  return new ApolloClient({
    cache: new InMemoryCache(),
    link: new HttpLink({
      uri: "http://localhost:3000/api/graphql",
      fetchOptions: {
        cache: "no-store",
        next: {revalidate: 0},
      },
    }),
  });
});

インスタンス生成時に指定したオプションをリクエストごとにオーバーライドしたい場合は、Query実行時にcontextfetchOptionsを指定することができます。

export default async function Page() {
  const {data} = await getClient().query({
    query: userQuery,
    context: {
      fetchOptions: {
        cache: "force-cache",
        next: {revalidate: 10},
      },
    },
  });

  return <p>data received during Page render: {JSON.stringify(data)}</p>;
}

指定できるオプションは、Next.jsのfetch関数で指定できるものと同じです。

https://nextjs.org/docs/app/api-reference/functions/fetch#fetchurl-options

キャッシュの仕組み

Request Memoization

Next.jsではRequest Memoizationにより、レンダリング中の同一のAPIリクエストはメモ化されて重複して実行されないようになっています。しかし、重複排除されるのはfetchによるGETリクエストのみで、GraphQLのようなPOSTリクエストは重複排除されません。
そのため、GraphQLの場合はReact.cacheを使用して、独自でAPIリクエストをメモ化する必要があります。

それを実現するために@apollo/client-integration-nextjsでは、getClientの内部でReact.cacheを使い、インスタンスを生成する関数をメモ化しています。
これにより、同じレンダリング中に呼び出されているgetClientは同じインスタンスを返すようになっています。

// 説明のために一部省略したコード
function makeGetClient<
  AC extends Promise<ApolloClient<any>> | ApolloClient<any>,
>(makeClient: () => AC): () => AC {
  function makeWrappedClient() {
    return { client: makeClient() };
  }

  // React.cacheを使用して、インスタンスの生成をメモ化
  const cachedMakeWrappedClient = cache(makeWrappedClient);

  function getClient() {
    // メモ化された関数を呼び出して取得したインスタンスを返す
    const wrapper = cachedMakeWrappedClient();
    return wrapper.client;
  }
  return getClient;
}

https://github.com/apollographql/apollo-client-integrations/blob/main/packages/client-react-streaming/src/registerApolloClient.tsx#L116C1-L164C2

同一レンダリング内でのリクエストの重複排除

前述の通り、getClient内部ではReact.cacheによるメモ化が行われているため、同じレンダリング中に複数回getClientが呼び出されても、同一のインスタンスが返されるようになっています。この仕組みにより、Apollo Clientのキャッシュ機能と組み合わせることで、同一リクエスト内でのAPIリクエストの重複を効率的に排除することができます。

具体的には、あるコンポーネントでQueryを実行した後、同じレンダリング内の別のコンポーネントでQueryを実行する場合を例に挙げます。
下記の例ではChildコンポーネントで実行するQueryは、インスタンスのキャッシュにデータがすでに存在するため、リクエストは実行されず、キャッシュからデータが取得されます。
これにより、同一レンダリング内でのリクエストの重複排除を実現しています。

const userQuery = gql`
  query {
    getUser(id: "1") {
      id
      name
    }
  }
`;

export default async function Page() {
  // リクエストが実行される
  const {data} = await getClient().query({query: userQuery});

  return (
    <>
      <p>data received during Page render: {JSON.stringify(data)}</p>
      <Child />
    </>
  );
}

const userIdQuery = gql`
  query {
    getUser(id: "1") {
      id
    }
  }
`;

const Child = async function () {
  // Pageコンポーネントで実行したQueryのキャッシュがあるので、リクエストは実行されない
  const {data} = await getClient().query({query: userIdQuery});

  return <p>data received during Child render: {JSON.stringify(data)}</p>;
};

ただし注意点として、この挙動はApollo Clientの通常のキャッシュの仕組みに依存しているため、キャッシュにデータがある場合のみ重複排除が機能します。つまり、キャッシュに少しでも足りないデータが存在する場合は、リクエストが実行されます。
上記の例では、userQuery->userIdQueryの順に実行されているため、userIdQueryで要求しているデータは全てキャッシュに存在しますが、下記のようにuserIdQuery->userQueryの順に実行されている場合は、userQueryで要求しているデータ(具体的にはnameフィールド)はキャッシュに存在しないため、リクエストが実行されます。

const userIdQuery = gql`
  query {
    getUser(id: "1") {
      id
    }
  }
`;

export default async function Page() {
  const {data} = await getClient().query({query: userIdQuery});

  return (
    <>
      <p>data received during Page render: {JSON.stringify(data)}</p>
      <Child />
    </>
  );
}

const userQuery = gql`
  query {
    getUser(id: "1") {
      id
      name
    }
  }
`;

const Child = async function () {
  // Pageコンポーネントで実行したQueryのキャッシュではデータが足りないので、リクエストが実行される
  const {data} = await getClient().query({query: userQuery});

  return <p>data received during Child render: {JSON.stringify(data)}</p>;
};

Apolloのキャッシュの仕組み自体は従来のものと変わらないので、詳細については下記の記事を参考にしてください!

https://zenn.dev/buyselltech/articles/b64935ea7d6fee

React.cacheの挙動

React.cacheによるキャッシュはRSCサーバーへのリクエストごとに無効化されます。

これにより、リクエストごとにインスタンスが破棄されるため、異なるユーザーからのリクエスト間で同じインスタンスが共有されることはありません。
これはセキュリティ面でも重要なポイントで、異なるユーザーからのリクエスト間で同じインスタンスが共有されると、個人情報などのデータが漏洩するリスクが出てくるため注意が必要です。

getClientの内部実装でも、異なるリクエスト間で同じインスタンスが共有されていないか検知して、警告を出す処理が実装されています。

// 説明のために一部省略したコード
function makeGetClient<
  AC extends Promise<ApolloClient<any>> | ApolloClient<any>,
>(makeClient: () => AC): () => AC {
  // ラップオブジェクトを使用することで、異なるリクエスト間で同じインスタンスが共有されていないか検知できる
  // ラップしないと同一リクエスト内での参照なのか、異なるリクエスト間での参照なのかの区別がつかない
  function makeWrappedClient() {
    return { client: makeClient() };
  }

  const cachedMakeWrappedClient = cache(makeWrappedClient);

  function getClient() {
    const wrapper = cachedMakeWrappedClient();
    if (seenWrappers && seenClients) {
      if (!seenWrappers.has(wrapper)) {
        // 異なるリクエスト間で同じインスタンスが共有されている可能性がある場合は警告を出す
        if (seenClients.has(wrapper.client)) {
          console.warn(/* ... */);
        }
        seenWrappers.add(wrapper);
        seenClients.add(wrapper.client);
      }
    }
    return wrapper.client;
  }
  return getClient;
}

Client ComponentsとSSR

App Routerにおける重要なポイントの1つに、CCのレンダリングの挙動があります。
SCはサーバーでのみレンダリングされますが、CCはサーバーとクライアントの両方でレンダリングされます。CCはページへの最初のリクエスト時やリロード時はサーバーでレンダリングされ、その後のナビゲーションではクライアント側でレンダリングされます。

https://nextjs.org/docs/app/building-your-application/rendering/client-components#how-are-client-components-rendered

そのため、@apollo/client-integration-nextjsのドキュメントでも、RSCとSSRを明確に使い分けていることが言及されています。

❗️ We do handle "RSC" and "SSR" use cases as completely separate.

CCはサーバーとクライアントの両方でレンダリングされるため、SSR時に実行されたCCのQueryはキャッシュの更新により、ブラウザ側で動的に更新されます。
しかし、SCで実行されたQueryは、ブラウザ側で動的に更新されることはありません。
そのため、SCとCCで同じデータを取得している場合、それぞれのデータに差分が生まれUIに不整合が生じる可能性があります。
これを避けるために、SCとCCで取得するデータは明確に分ける必要があります。

まとめ

今回はApp RouterとApollo Clientを組み合わせた実装方法と内部の仕組みについて紹介しました!

RSCとGraphQLは同じ課題に対する異なるアプローチなので、新規プロダクトでゼロから開発する場合は、併用することはあまりないかもしれません。個人的には、どちらか一方に寄せる方が良いと思っています。

しかし、現状のプロダクトですでにGraphQLを採用していて、App Routerへの移行を検討している場合は、しばらくの間は併用することになるかと思います。そういった場合に、今回紹介した内容が参考になれば嬉しいです!

また、RSCとGraphQLを併用する場合の設計方針として、全てCCに寄せるなど、いくつかのアプローチが考えられます。それらについても検討の余地があるので、別の機会に紹介したいと思います。

株式会社ドクターズプライム

Discussion