Apollo Client/GraphQLで意図しないキャッシュが効いてしまう問題の解決策

はじめに

カイポケリニューアルのフロントエンドエンジニアの原野です。2023年10月より入社し、前職ではiOS/Webのエンジニアをしていました。

カイポケリニューアルプロジェクトでは、データ取得にGraphQLを採用しており、クライアントのライブラリとしてApollo Clientを使用しています。

GraphQLは、データの要求と取得をより効率的に行うことができる技術です。しかし、その機能を直接fetchメソッドで扱う場合、複雑な状態管理やキャッシュ戦略を自前で実装する必要があります。
ここでApollo Clientの出番です。
Apollo Clientは、これらの課題を解決するための強力なライブラリであり、開発者がデータの取得、管理、キャッシュの最適化を容易に行えるよう支援します。

今回、開発の中でApollo Clientのキャッシュの仕組みを理解するタイミングがあったため、その内容を共有したいと思います。

想定読者

想定読者として、Apollo ClientやGraphQLについて概要を理解しており、実際に利用したことがある、もしくは利用を検討している人などを想定しています。

Apollo Clientのキャッシュ機能の理解

Apollo Clientの魅力の一つは、フェッチしたデータを効率的にキャッシュする機能にあります。Apollo Clientは、取得したデータをオブジェクト単位で正規化し、それをキャッシュに保存します。
例を挙げると、以下のGraphQLのスキーマがあるとします。

type Team {
   id: ID!
   name: String!
   members: [Member!]!
}

type Member {
   id: ID!
   name: String
}

このスキーマで、TeamオブジェクトとそのMemberオブジェクトがフェッチされると、Apollo Clientはそれぞれを独立したエンティティとしてキャッシュします。キャッシュのキーは、エンティティのtype名とIDを連結して生成されます。(例:Team:cGVvcGxlOjE=)

正規化をすることにより、もし複数のコンポーネントが同じデータを要求する場合でも、Apollo Clientは既にキャッシュされたデータを再利用することで、不要なネットワークリクエストを削減できます。

例えばプロジェクト内で、チーム情報とメンバー情報を表示するページが複数ある場合、Apollo Clientのキャッシュは自動的にこれらの情報を管理し、ページ間でのデータの一貫性を保ちながらリクエストの数を減らすことができます。

発生した問題とその解析

Apollo Clientのキャッシュ機能を使用する中で、期待と異なるデータ取得の問題に直面しました。
この問題は、クエリ(A)を実行するとその結果がキャッシュされ、その後クエリ(B)を実行すると、(B)の結果に(A)のキャッシュ結果が影響を与えてしまうというものです。

上の例で言うと、クエリ(A)では「Teamのmembersにはチーム全員」が含まれ、クエリ(B)には「membersに特定期間所属している、membersをフィルタした結果」が入っているケースなどです。
この場合、Team自体が同じIDを持っていて、子要素のmembersの値が異なっていた場合でも、親であるTeamのIDが一致している為、Apollo Clientはキャッシュの値を利用し、サーバーへの再取得を行いません。

初期の解決策とその限界

この問題に対処するために、最初に試した解決策はfetchPolicyをno-cacheに設定してキャッシュを完全に無視することでした。これにより、常に最新のデータをサーバーから取得することができます。しかし、この方法ではrefetchQueryを使用したときにデータが更新されないという新たな問題が発生しました。

refetchQueryの仕様の深掘り

バックエンドのデータを更新した後、フロントエンドでその変更を即座に反映させたい場面でApollo ClientにはrefetchQuery機能が用意されています。
mutationの際にrefetchQueryを指定すると、完了後にrefetchQueryで取得したQueryが再取得されます。

以下の例だと、update実行時にQueryAで取得したデータが再取得されます。

const [update, { loading, error }] = useMutation(MUTATION_QUERY, {
  refetchQueries: [
    'QueryA',
  ],
});

しかし、fetchPolicyの設定によっては、期待したようにデータの更新がトリガされないことが判明しました。Apollo Clientのソースコードを詳細に調べた結果、特定のfetchPolicy設定下ではshouldNotifyがfalseに設定され、結果として更新が行われないことが明らかになりました。

この挙動は、当時自分が探した中ではApolloの公式ドキュメントに明確には説明されておらず、遭遇した当初は原因の把握に時間がかかりました。

github.com

採用した解決策: 新たな型の導入

最終的に、期間でフィルタされたMemberを含むTeamオブジェクトをTeamWithFilteredMemberとして新たな型で定義することで、この問題を解決しました。
Apollo Clientはこの新しい型を別のエンティティとして扱い、期間フィルタされたクエリと全メンバーを含むクエリの結果を適切に区別してキャッシュします。

学び

Apollo Clientのキャッシュ機能は非常に強力ですが、その内部動作を理解せずに使用すると予期せぬ挙動に直面することがあります。特に、同じオブジェクトの異なる表現を扱う場合、キャッシュ戦略を慎重に考える必要があり、場合によっては似た情報でも別のオブジェクトとして定義する必要もあります。

まとめ

Apollo Clientは便利ですが、機能も複雑化しており問題に遭遇した際の解決が難しい場合もあります。この記事が似たような問題に遭遇した方の解決に役立てば嬉しいです。

参考文献

https://www.apollographql.com/docs/react/caching/overview/ https://github.com/apollographql/apollo-client