初めて Apollo Client を使うことになったらキャッシュについて知るべきこと

この記事は GraphQL Advent Calendar 2020 2日目の記事です。

前回は mtsmfm さんの記事でした。
GraphQL は REST などと同じく、DB のテーブルをそのまま露出するとは限らない - Qiita

はじめに

こんにちは、 shinnoki です。

フロントエンドで用いる GraphQL のクライアントは複数の選択肢が存在しますが、 React + TypeScript なプロジェクトでは Apollo Client が採用されることが多いのではないでしょうか。

Apollo Client の特徴のひとつに強力なキャッシュ機構がありますが、逆に理解しないと意図しない挙動になることもあったりと、初学者にとってはなかなかイメージを掴みづらいものになっています。

まず最初に結論として、「初めて Apollo Client を使うことになったけど、とにかく早く実装にとりかからなければならない!」となっても、次の4つを覚えておけば大丈夫です。

  1. 取得するフィールドに id は必ず含める
  2. 更新処理のときは Mutation のレスポンスでオブジェクトのキャッシュを更新する
  3. 作成、削除処理のときは refetchQueries などを使い配列のキャッシュを更新する
  4. 画面表示のたびに最新のデータを表示したければ fetchPolicy: "cache-and-network" を使う

以下これらについて詳しく見ていきますが、後からでも大丈夫ですので、慣れてきたら理解するといいでしょう。

Apollo Client のキャッシュを覗いてみる

ChromeFirefoxApollo Client Devtools のアドオンを追加すると、開発者ツールの中に Apollo が追加され、この中でキャッシュの状態などを確認できるようになります。

Apollo Client Devtools を使ったデバッグをする際に特にコードの変更は必要なく、開発時は有効で Production Build 時は無効にしてくれます。

以下のように React で実装されたブログのような投稿とそれに紐づくコメントを取得するページを例として考えてみましょう。

import { gql, useQuery } from "@apollo/client";

const POSTS_QUERY = gql`
  query Posts {
    posts {
      id
      text
      comments {
        id
        text
      }
    }
  }
`;

const Posts = () => {
  const { loading, error, data } = useQuery(POSTS_QUERY);

  if (loading) return "Loading...";
  if (error) return `Error! ${error.message}`;

  return (
    <ul>
      {data.posts.map((post) => (
        <li key={post.id}>
          {post.text}
          {post.comments.length > 0 && (
            <ul>
              {post.comments.map((comment) => (
                <li key={comment.id}>{comment.text}</li>
              ))}
            </ul>
          )}
        </li>
      ))}
    </ul>
  );
};

export default Posts;

このページを表示し、 Apollo Client DevTools の中の Queries タブ を開くと Posts クエリが発行されていることが確認できます。

f:id:konoki_nannoki:20201202171358p:plain

この状態で Cache タブを開くと、以下のようにキャッシュの中身が確認できます。

f:id:konoki_nannoki:20201202171856p:plain

ROOT_QUERY
  posts:
    0:{"__ref":"post:1"}
    1:{"__ref":"post:2"}

というように表示されていますが、これは Posts クエリの結果として post:1post:2 というキャッシュオブジェクトが参照されているということです。

post:1post:2 はキャッシュオブジェクトのキーで、このように固有のキーを生成しオブジェクトを分解してキャッシュを保存することを正規化といいます。

キーが同じ場合同じオブジェクトを指すことを意味し、複数の Query や Mutation の結果として同じキーのオブジェクトが登場した場合、そのオブジェクトの最新のデータが取得されたとみなし結果がマージされてきます。

キーはデフォルトでは __typenameid: でつなげて生成されます。

__typenamepostcomment などのオブジェクトの種類を表すものでライブラリが自動で付与してくれるので通常は気にする必要はありませんが、 id は取得してくるように意識しないとキーが生成できないので注意する必要があります。

キャッシュの設定によって id 以外のフィールドをキーにすることも可能ですが、その場合キャッシュの設定をメンテナンスしていかなければならなくなるため、通常まずは id を取得することをルール化するといいでしょう。

Configuring the cache - Client (React) - Apollo GraphQL Docs

試しに POSTS_QUERY から postcommentid の取得を削除してみると、以下のようなキャッシュになります。

f:id:konoki_nannoki:20201202200722p:plain

これはキーを生成できなかったためキャッシュを正規化できておらず、実際は同じオブジェクトを指す場合でもキーを特定できずに結果をマージできないため、キャッシュの本来の力を引き出せていない状態です。

次に進む前に、消した id は戻しておきましょう。

更新処理では Mutation のレスポンスを活用する

今回はお手軽に Apollo Client Devtools 内の GraphiQL タブから Mutation を実行してみます。

mutation UpdatePost($id: Int!, $text: String!) {
  update_post_by_pk(
    pk_columns: { id: $id }
    _set: { text: $text }
  ) {
    id
    text
  }
}

update_post_by_pk は今回 GraphQL API として使っている Hasura 特有の書き方ですが、 Post をアップデートする処理として読んでいただければと思います。

はじめは Mutation の { } の中に何を書いていいかわかりづらいと思いますが、 { } の中に書くのは Mutation の実行後にレスポンスとして取得するフィールドです。

上記の Mutation のように idtext を結果として取得すると、 Mutation の実行後に画面の Post 一覧も更新されます。

f:id:konoki_nannoki:20201202204141p:plain

これは id を取得していることにより post:1 というキーの正規化されたキャッシュオブジェクトがマージされるためです。

id を取得しないとキーが特定できないのでキャッシュが更新されず、逆に id を取得していても text を取得していない場合、キャッシュオブジェクトはマージしようとするのですが最新の text を取得していないのでやはりキャッシュは更新されません。

Mutation の場合も Query と同様 id は必ず含めるようにし、 Mutation によって更新されるフィールドをレスポンスで取得することでキャッシュを更新しましょう。

追加、削除処理では Mutation のレスポンスだけでは難しい

更新処理のときはキャッシュオブジェクトのマージを活用することで簡単に更新できましたが、投稿が追加や削除されたときに Posts 一覧のアイテムを追加や削除するにはどうすればいいでしょう。

実はこれが更新のように簡単にはいかず、 refetchQueries で Query を更新するか自分でコードを書いて Mutation のレスポンスをもとにキャッシュを更新する必要があります。

refetchQueries で Query を更新するには、以下のようにコードを書きます。

ADD_POST_QUERY は具体的な内容は省略しますが投稿を追加する Mutation として読み替えて下さい。

const [addPost] = useMutation(ADD_POST_QUERY, {
  refetchQueries: ["Posts"],
  awaitRefetchQueries: true,
});

この書き方はとても楽ですが、 Mutation が完了した後に refetchQueries の数だけ通信して Query を更新するためパフォーマンス的には不利です。

自分でコードを書いてキャッシュを更新すれば1回の通信で済むため、初期の実装やキャッシュに対する理解が追いついていないうちは refetchQueries を使い、徐々にパフォーマンスを改善していくのがいいでしょう。

Mutation のレスポンスをもとにキャッシュを更新する具体的な方法については中級者向きになりますので今回は割愛します。

デフォルトではキャッシュを優先して返し無駄な通信が発生しないようになっている

今までみてきた Apollo Client のキャッシュは SPA 内ので画面遷移などをしても保持されるため、例えば他の画面に遷移して戻ってきても、キャッシュがあれば通信は発生せずキャッシュの内容を返すようになっています。

これはデフォルトの fetchPolicycache-first になっているためです。

ApolloClient - Client (React) - Apollo GraphQL Docs

自分しかデータを変更しないのであれば今までみてきた使い方を守れば常にキャッシュは最新の状態になりますが、他でデータが変更された場合、キャッシュに残った古いデータを表示し続けてしまう可能性があります。

画面を表示するたびに通信して最新の状態を表示するためには、 fetchPolicy: "cache-and-network" を指定しましょう。

const { loading, error, data } = useQuery(POSTS_QUERY, {
  fetchPolicy: "cache-and-network",
});

fetchPolicy はこのように個別の useQuery で設定することも、 ApolloClient を作成するときにグローバルに設定することも可能です。

まとめ

Apollo Client のキャッシュ機構を使うと、従来は Redux Middleware に持たせることが多かった通信のステートを Apollo Client が内部で管理してくれるため、 Redux Middleware を使うよりもシンプルに書くことができます。
(画面上で複雑な状態を保持する必要がある場合など依然として Redux が有用な場面もあり、完全に置き換えるものではありません。)

まずは「はじめに」に書いた4つの使い方を覚えて、徐々に使いこなしていくと良いでしょう。

GraphQL Advent Calendar 2020 明日の記事は qsona さんの予定です。お楽しみに!