🚀

Apollo Clientのキャッシュをちゃんと理解する

に公開

はじめに

本記事ではApollo Clientのキャッシュに焦点を絞って確認をしていきます。

  • キャッシュのライフサイクルがどうなっているか
  • 任意にキャッシュの操作を行うにはどうしたらいいのか

などについて理解する事を目的にしています。
これからApollo Clientを使われる方、既に使ってはいるが何となく理解のまま進んでいる方などの参考になれば幸いです。

Apollo Clientのキャッシュの基本を理解する

ライフサイクルについて

まずはApollo Clientのライフサイクルを図で確認してみます

参照: Apollo 公式ドキュメント

まずは順を追ってキャッシュの基本について確認していきましょう。

  1. Apollo Clientはクエリが投げられたらまずInMemoryCacheを確認しにいきます。
  2. InMemoryCacheにキャッシュがない場合GraphQL Severにクエリを投げます。
  3. 使い回しが出来る様InMemoryCacheにキャッシュを保存します。
  4. Apollo Clientにデータを返します。
    以降同じクエリを投げた場合はInMemoryCacheにデータがあるのでGraphQL Severにはいかずそのままキャッシュのデータを返す事ができます。
    InMemoryCacheとGraphQL Sever間の通信が不要になるのでその分早くレスポンスを返す事ができると言うわけです。

これがデフォルトのライフサイクルになりますが状況によってはオフライン環境でも動作する様にキャッシュしか使いたくない場合や逆に必ず新鮮なデータが欲しい場合などがあると思います。
これらを簡単に切り替えできる様にApollo ClientにはFetchPoliciesなるものがあります。
FetchPoliciesは全部で5つあります。

ポリシー名 説明
cache-first デフォルトの設定です。ネットワークリクエストの数を最小限に抑えることを優先している。キャッシュに全てのフィールドのデータがあれば返す。なければGraphQLサーバーにクエリをリクエストする。クエリ結果をキャッシュする。
cache-and-network 高速な応答を提供しつつ、サーバーとローカルデータの一貫性を維持できる。キャッシュとGraphQLサーバーの両方にクエリを実行し、サーバ側のクエリ結果をキャッシュする。
network-only サーバーとローカルデータの一貫性を常に保てる。キャッシュを参照せず、GraphQLサーバーに常にリクエストする。クエリ結果はキャッシュする。
no-cache サーバーとローカルデータの一貫性を常に保つが、キャッシュは更新しない。キャッシュを参照せず、GraphQLサーバーに常にリクエストする。クエリ結果はキャッシュしない。
cache-only キャッシュに全てのフィールドのデータがあれば返す。なければ、エラーをスローする。
standby cache-firstと同じロジックだが、フィールドが更新されても自動的に更新しない。

キャッシュがどの様に保存されるか

次にキャッシュがどの様に保存されているか確認していきます。
以下、公式ドキュメントより引用しています。

Apollo Client は、相互参照可能なオブジェクトのフラットなルックアップテーブルとしてデータを保存します。

ブラウザの翻訳機能を使用しているのも相まって少々わかり辛い表現となっていますが、つまりはネストされたオブジェクトデータであっても全て同一階層に揃えてルックアップテーブルに保存すると言うことになります。ただしデータの階層をフラットにする為にはデータを正規化する必要があります。
せっかくなのでどの様にしてApolloがデータを正規化しているか確認しましょう。

データの正規化について

はじめになぜ正規化が必要なのか確認します。
GraphQLのレスポンスは通常、ネストした構造(入れ子構造)で返ってきます。この構造のまま単純にキャッシュすると、以下のような問題が発生します
問題1: データの重複

// 同じPlanetデータが複数の人物データに含まれる
const lukeData = {
  person: {
    name: "Luke Skywalker",
    homeworld: { name: "Tatooine", climate: "arid" }
  }
}

const anakinData = {
  person: {
    name: "Anakin Skywalker", 
    homeworld: { name: "Tatooine", climate: "arid" } // 重複
  }
}

問題2: 一貫性の維持が困難
もしTatooineの気候情報が更新された場合、すべての関連するキャッシュを手動で更新する必要があります。
問題3: メモリの無駄遣い
同じデータが複数箇所に保存されるため、メモリ使用量が増加します。

正規化によるメリット
データを正規化することで、これらの問題を解決できます。

データの一意性: 各オブジェクトは一度だけ保存される
自動的な一貫性: 一箇所を更新すれば、参照している全ての場所に反映される
メモリ効率: 重複データがないため、メモリ使用量を削減
部分的な更新: 特定の必要なオブジェクトのみを効率的に更新可能

これらの理由から、Apollo Clientは受け取ったデータを自動的に正規化してキャッシュに保存します。
では、具体的にどのような手順で正規化が行われるのか確認していきましょう。

今回使用するモックデータ
{
  "data": {
    "person": {
      "__typename": "Person",
      "id": "cGVvcGxlOjE=",
      "name": "Luke Skywalker",
      "homeworld": {
        "__typename": "Planet",
        "id": "cGxhbmV0czox",
        "name": "Tatooine"
      }
    }
  }
}

1. オブジェクトを識別する
まず、キャッシュはレスポンスに含まれるすべての個別のオブジェクトをidで識別します。

  • Personid cGVvcGxlOjE=
  • Planetid cGxhbmV0czox

2. キャッシュIDを生成する
各オブジェクトに対してキャッシュIDを生成する
このキャッシュIDでInmemoryCacheにデータが存在するか識別します。
キャッシュIDはデフォルトでは__typenameid (または_id)によって生成されます。
例: Person:cGVvcGxlOjE=
カスタマイズも可能

3. オブジェクトフィールドを参照に置換える

置換え前
{
  "__typename": "Person",
  "id": "cGVvcGxlOjE=",
  "name": "Luke Skywalker",
  "homeworld": {
    "__typename": "Planet",
    "id": "cGxhbmV0czox",
    "name": "Tatooine"
  }
}

このレスポンスの場合次のように参照へ置換えられます

置換え後
{
  "__typename": "Person",
  "id": "cGVvcGxlOjE=",
  "name": "Luke Skywalker",
  "homeworld": {
    "__ref": "Planet:cGxhbmV0czox" // 参照へ置換えられた
  }
}

4. 正規化されたオブジェクトを保存する
最後にキャッシュのオブジェクトは全てフラットルックアップテーブルに保存されます。

レスポンスオブジェクトのキャッシュIDがInMemoryCacheにあるキャッシュIDと同じ場合、それらのオブジェクトのフィールドはマージされます。
レスポンスオブジェクトとInMemoryCacheのオブジェクトがフィールドを共有している場合、レスポンスオブジェクトはそのフィールドのキャッシュされた値を上書きします。

InMemoryCacheのオブジェクトのみ、またはレスポンスオブジェクトのみに表示されるフィールドはそれぞれ別で保持されます。

通信時のキャッシュ動作について理解する

Apollo Clientのキャッシュの基本を抑えたので実際にQueryMutation実行時、キャッシュがどう変化しているのかコードも交えて確認していきます。

Queryが投げられた際のキャッシュについて

まずはQueryから確認します。
例として犬の画像を取得するQueryを定義します。

query Dog($breed: String!) {
  dog(breed: $breed) {
    __typename
    id
    displayImage
  }
}

取得した画像を表示するコンポーネントです。

function DogPhoto({ breed }) {
  const { loading, error, data } = useQuery(GET_DOG_PHOTO, {
    variables: { breed },
  });

  if (loading) return <Loading />; // よくあるクルクル回るやつ
  if (error) return `Error! ${error}`;

  return (
    <img src={data.dog.displayImage} style={{ height: 100, width: 100 }} />
  );
}

breedにはshibaが渡って来たとしてレスポンスは以下の値が返ってきたと仮定します。

{
  "data": {
    "dog": {
      "__typename": "Dog",
      "id": "cGxhbmV0czox",
      "displayImage": "https://images.dog.ceo/breeds/shiba/shiba-8.jpg"
    }
  }
}

ここでライフサイクルのおさらいです。
まずはInMemoryCacheを見に行きますが今回のQueryは初めて投げられたものなのでInMemoryCacheにキャッシュは存在しません。
ですのでApolloはGraphQL Severまで問い合わせをしにいきます。返ってくる際にデータを正規化してInMemoryCacheにキャッシュを保存してからApollo Clientへデータを渡します。
この時のキャッシュIDは__typename + idなのでDog:cGxhbmV0czoxとなりこのIDをもとにオブジェクトは正規化されます。もちろんApollo Clientにデータが来るまでの間は<Loading />が描画されています。
さあ、もう一度DogPhotoコンポーネントが呼ばれたとしましょう。
同じくbreedにはshibが渡って来ました。しかし今回はInMemoryCacheのルックアップテーブルにDog:cGxhbmV0czoxとしてデータが正規化された状態でキャッシュ保存されていますのでこの値をApollo Clientに返します。GraphQL Severまでデータを取りにいく必要がないため一瞬で<img>が表示されることになります。
これこそがキャッシュの最大メリットと言えます。ローディングのアニメーションがあればユーザーは通信中という事を認識できますが、あのクルクルにはユーザーに想像以上のストレスを与えます。例え短い時間であってもボタンを押す度にクルクルしたり不意にクルクルされるとイライラして間違いなくサービスから離脱しますので如何に早く表示できるか通信回数を減らせるかはサービスとしてとても重要な事なのです。

キャッシュデータの更新について

Apollo Clientにはキャッシュを更新する手段としてポーリングリフェッチの2つの方法があります。両者キャッシュ更新を行うと言う点では同じですが利用シーンが異なるので抑えておきましょう。

ポーリング

指定した間隔で定期的にクエリを実行する事ができる機能です。特にトリガーとなるイベントは無いが最新のデータが欲しい場面などで利用されます。

function DogPhoto({ breed }) {
  const { loading, error, data } = useQuery(GET_DOG_PHOTO, {
    variables: { breed },
    pollInterval: 500, // この値(単位はms)の間隔でクエリが定期実行される
  });

  if (loading) return null;
  if (error) return `Error! ${error}`;

  return (
    <img src={data.dog.displayImage} style={{ height: 100, width: 100 }} />
  );
}

リフェッチ

何かトリガーとなる特定のイベント時に最新のデータが欲しい場面などで利用されます。

function DogPhoto({ breed }) {
  const { loading, error, data, refetch } = useQuery(GET_DOG_PHOTO, {
    variables: { breed },
  });

  if (loading) return null;
  if (error) return `Error! ${error}`;

  return (
    <div>
      <img src={data.dog.displayImage} style={{ height: 100, width: 100 }} />
      <button onClick={() => refetch()}> // クリック時に再度クエリが実行される
        更新
      </button>
    </div>
  );
}

refetch時のfetchPolicyについて
refetchを実行する際、ネットワークリクエストを保証するためにfetchPolicyが以下のように調整されます。

基本動作: 元クエリのfetchPolicyに関係なくnetwork-onlyが設定される
例外: 元のクエリのfetchPolicyがno-cacheまたはcache-and-networkの場合は、元のfetchPolicyが維持される

つまり、いずれの場合でもrefetchはネットワークリクエストが実行される事になります。

const { refetch } = useQuery(GET_DOG_PHOTO, {
  fetchPolicy: 'cache-first', // 初回は cache-first
});

refetch(); // refetch時は network-only として実行される
const { refetch } = useQuery(GET_DOG_PHOTO, {
  variables: { breed },
  fetchPolicy: 'cache-and-network', // キャッシュとネットワーク両方からデータを取得
});

// この場合、refetch時もcache-and-networkの挙動が維持される
refetch();

notifyOnNetworkStatusChangeとの関係
notifyOnNetworkStatusChange: trueを設定すると、refetch中のローディング状態を詳細に追跡できます

const { loading, networkStatus, refetch } = useQuery(GET_DOG_PHOTO, {
  variables: { breed },
  notifyOnNetworkStatusChange: true,
});

// loadingはrefetch中もtrueになる
// networkStatusで色々な状態を確認可能(1: loading, 4: refetch など)

このように、refetchは基本的にネットワークリクエストを強制実行しますが、元のクエリの設定によって細かい挙動が変わることを理解しておくことが重要です。
もちろん引数も渡す事は可能です。

<button
  onClick={() =>
    refetch({
      breed: 'dalmatian', // クリックの度に最新のダルメシアンのデータを取ってくる
    })
  }
>
 更新
</button>

Mutationのキャッシュ更新

mutaion実行時も基本的にqueryの時と同様にキャッシュが保存されます。例えば以下のmutation実行したとします。

mutation UpdateDog($id: ID!, $name: String!) {
  updateDog(id: $id, name: $name) {
    id
    name
  }
}

query同様mutationのレスポンスの__typenameidによってキャッシュIDが生成されオブジェクト単位でキャッシュに保存されます。

mutation実行時に特定のqueryを実行させキャッシュを更新したい時はrefetchQueriesを使用します。

  const [updateDog] = useMutation(UPDATE_DOG, {
    refetchQueries: [{ query: GET_DOGS }], // mutation実行時に指定のqueryを実行する
  });

レスポンスを利用してキャッシュを更新したい場合はupdate関数を使用します。

const [updateDog] = useMutation(UPDATE_DOG, {
  update(cache, { data }) {
    if (!data?.updateDog) return;

    const updatedDog = data.updateDog;

    cache.writeFragment({
      id: cache.identify(updatedDog), // 更新するキャッシュIDを指定
      fragment: gql`
        fragment UpdatedDog on Dog {
          name
        }
      `,
      data: {
        name: updatedDog.name, // レスポンスのnameで更新
      },
    });
  },
});

キャッシュ構造を直接操作する

ある程度キャッシュの更新について確認してきましたがこれだけではまだ十分ではありません。特に、mutation後の一覧の更新や、ページネーションの追記、部分的なフィールド変更といったケースでは、開発者自身がキャッシュを明示的に操作する必要があります。
この章では、Apollo Clientが提供する低レベルなキャッシュ操作API(cache.modify、writeQuery、mergeなど)を使って、キャッシュ構造を直接制御する方法とその使い分けについて解説していきます。

cache.modify

cache.modify は、Apolloのキャッシュに格納された特定のフィールドを直接編集するためのAPIで、操作には通信を必要としません。
特に「配列の追加・削除・並び替え」など、一覧データの構造を部分的に変更したい場合に適しています。
例えばドラッグ&ドロップなど実装する際などに有効です。

cache.modify({
  fields: {
    tasks(existingTaskRefs: Reference[] = [], { readField }) {
      // 並び替え後の順序(外部で計算済み)
      const newOrder = reorderTaskRefs(existingTaskRefs, sourceIndex, destinationIndex);

      return newOrder;
    },
  },
});

cache.writeQuery / cache.readQuery

writeQueryとreadQueryは、Apolloのキャッシュに対してクエリ形式で直接読み書きできるAPIです。
mutation実行後、関連する一覧や詳細情報を手動で更新したいときによく使われます。

const existing = cache.readQuery({ query: GET_DOGS });

cache.writeQuery({
  query: GET_DOGS,
  data: {
    dogs: [...existing.dogs, newDog],
  },
});

typePolicies.merge

mergeは、あるフィールドに対してApolloが新しいデータをキャッシュに取り込むとき、既存データとどのようにマージするかを定義する関数です。
たとえばページネーションで 1ページ目、2ページ目と順番にデータを取得した場合、それぞれをキャッシュ上で結合して一覧として扱いたいときに使用します。

Apollo Clientはクエリの引数をもとに、キャッシュキーを自動で生成しています。
ただし、すべての引数がキャッシュキーに含まれると、ページごとに別キャッシュとして保存されてしまい、マージできません。
そのためkeyArgsを使って、「マージの対象になる引数」と「ならない引数」を明示的に指定する必要があります。

typePolicies: {
  Query: {
    fields: {
      dogs: {
        keyArgs: ['breed'], // 品種毎にキャッシュを分ける
        merge(existing = [], incoming, { args }) {
          const offset = args?.offset ?? 0;
          const merged = [...existing];
          for (let i = 0; i < incoming.length; i++) {
            merged[offset + i] = incoming[i];
          }
          return merged;
        },
      },
    },
  },
}

keyArgsにはfalseを渡す事も可能です。その場合はキャッシュキーは生成されないため1つにまとまります。

const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        dogs: {
          keyArgs: false, // すべての引数を無視し、1つのdogsフィールドとして扱う
          merge(existing = [], incoming) {
            return [...existing, ...incoming];
          },
        },
      },
    },
  },
});

最後に

これでApollo Clientのキャッシュをある程度理解出来たのではないでしょうか。
この記事は公式ドキュメントが読みやすくなるように意識して書いたつもりなのでぜひ公式ドキュメントを読んで見て下さい。
まだまだ、色んな発見や学びがたくさんあると思います。

https://www.apollographql.com/docs/react

もし、記事内に間違った認識の箇所がありましたらコメントで指摘してもらえるとありがたいです。

Discussion