Next.js App Router × Apollo Clientにおける実装方法と内部の仕組み
最近のプロジェクトでNext.jsのApp Routerへの移行を進めていて、その過程でApp RouterとApollo Clientの連携について色々とキャッチアップする機会がありました。
そこで今回は、App RouterとApollo Clientを組み合わせた場合の実装方法と内部の仕組みについて紹介したいと思います!
記事の中で使用するサンプルコードは下記にあるので、興味があれば見てみてください。
App Router + Apollo Clientの実装方法
App RouterとApollo Clientを組み合わせる場合は、@apollo/client-integration-nextjsを使用します。
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/client
のApolloProvider
では、すでにインスタンス化された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実行時にcontext
でfetchOptions
を指定することができます。
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
関数で指定できるものと同じです。
キャッシュの仕組み
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;
}
同一レンダリング内でのリクエストの重複排除
前述の通り、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のキャッシュの仕組み自体は従来のものと変わらないので、詳細については下記の記事を参考にしてください!
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はページへの最初のリクエスト時やリロード時はサーバーでレンダリングされ、その後のナビゲーションではクライアント側でレンダリングされます。
そのため、@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