🫠

Apollo ClientではIDを必ずつけよう、ないなら設定しよう

2023/09/26に公開

TL;DR

Apollo Clientを使うときは必ずIDフィールドをQueryに含めよう。ないなら後述する設定を使おう。

Apollo Client

Apollo ClientはGraphQLクライアントであり、キャッシュや状態管理など多くの機能も併せ持つかなり強力なライブラリとして知られています。

昨今のフロントエンド開発においてGraphQLを導入するプロダクトの場合、デファクトスタンダートと言って良いほど使用されているのではないでしょうか。

Apollo Clientについては@seyaさんの「世のフロントエンドエンジニアにApollo Clientを布教したい」で詳しく解説されていますのでざっと確認したい方はこちらを参照ください。

複雑なライブラリであるということ

Apollo Clientは非常に便利な機能を提供しているが故に内部はとても複雑です。実際に「あなたのプロダクトに Apollo Client は必要ないかもしれない」といったようなテックブログが書かれるほどです。

が、今回はその複雑さのうち実際に直面したキャッシュに関する話をしようと思います。

Apollo Clientのキャッシュ

Apollo Clientのキャッシュについてはすでにたくさんの記事で紹介されていますが、この後の内容と深く関係があるためここでも概要を説明します。

データの保存方法

Apollo ClientはGraphQLクエリの結果を正規化し、インメモリキャッシュに保存します。
その際にデータを相互参照可能な形式に変換します。例えば以下のような結果が返ってきた場合を考えてみます。

クエリの結果
{
  "data": {
    "person": {
      "__typename": "Person",
      "id": "cGVvcGxlOjE=",
      "name": "Luke Skywalker",
      "homeworld": {
        "__typename": "Planet",
        "id": "cGxhbmV0czox",
        "name": "Tatooine"
      }
    }
  }
}

上記のデータの場合、2つのオブジェクトに分解して考えることができます。

  1. Personオブジェクト
Personオブジェクト
"person": {
  "__typename": "Person",
  "id": "cGVvcGxlOjE=",
  "name": "Luke Skywalker",
  "homeworld": {
    // Planetオブジェクト
  }
}
  1. Planetオブジェクト
Planetオブジェクト
"homeworld": {
  "__typename": "Planet",
  "id": "cGxhbmV0czox",
  "name": "Tatooine"
}

cache IDを生成

そして結果を各オブジェクトに分けることができれば次にcache IDを生成します。

デフォルトでは、cache ID = <__typenameの値>:<id(または_id)> の形になります。

つまり今回の場合、それぞれ以下のようになります。

  1. Personオブジェクトのcache Id = Person:cGVvcGxlOjE=
  2. Planetオブジェクトのcache Id = Planet:cGxhbmV0czox

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

生成したcache IDを使って元のオブジェクトを以下のようにします。
これでhomeworldフィールドはPlanetオブジェクトへの参照を持つようになりました。

オブジェクト
 {
   "__typename": "Person",
   "id": "cGVvcGxlOjE=",
   "name": "Luke Skywalker",
   "homeworld": {
-    "__typename": "Planet",
-    "id": "cGxhbmV0czox",
-    "name": "Tatooine"
+    "__ref": "Planet:cGxhbmV0czox"
   }
 }

もしも別のPersonオブジェクトが同じcache IDのPlanetオブジェクトを持っていた場合は同じ参照が保存され、データの重複を防ぐことができます。

データをマージする

データをできる限り小さい単位のオブジェクトに分けることができたわけですが、同じオブジェクトのデータを別のクエリで取得した場合(異なるフィールドのデータを取得している場合)、Apollo Clientはオブジェクトを自動でマージします。

そのマージ方法はとても直感的で以下の挙動をします。

既存のデータ
"person": {
  "__typename": "Person",
  "id": "hogehoge",
  "name": "fujiyamaorange",
  "color": "orange"
}

そして別のクエリで以下の結果を受け取ったとします。

新しいデータ
 "person": {
   "__typename": "Person",
   "id": "hogehoge",
+  "color": "blue",
+  "favorite": "coffee"
 }

この場合、キャッシュに保存されるオブジェクトは以下になります。同じフィールドを持っている場合は新しいデータにより更新されます。

詳細な挙動については引用として掲載しておきます。

キャッシュに保存されるデータ
 "person": {
   "__typename": "Person",
   "id": "hogehoge",
+  "name": "fujiyamaorange",
+  "color": "blue",
+  "favorite": "coffee"
 }
  • If the incoming object and the existing object share any fields, the incoming object overwrites the cached values for those fields.
  • Fields that appear in only the existing object or only the incoming object are preserved.

キャッシュが正しく保存されないケース

前置きが非常に長くなりましたが、ここからが本番です。ソースコードを元にApollo Clientの挙動について詳細に見ていきます。

セットアップ

今回はApollo Clientを実際に試したいのでSpaceXのGraphQL APIを使います。

各パッケージのバージョンは以下です。

package.json
{
  "dependencies": {
    "@apollo/client": "^3.8.3",
    "@graphql-codegen/cli": "^5.0.0",
    ...
  }
}

IDフィールドがないスキーマ

まずはrocketオブジェクトのうちnameだけを取得するものとcountryだけを取得するクエリを用意します。それぞれのrocketオブジェクトには IDフィールドを記述しません。

IDフィールドのないクエリ
### Rocket.graphql ###
query Rockets {
  launches {
    id
    rocket {
      rocket {
        name # nameを取得
      }
    }
  }
}

### Rocket2.graphql ###
query Rockets2 {
  launches {
    id
    rocket {
      rocket {
        country # countryを取得
      }
    }
  }
}

上記のスキーマからGraphQL Code Generatorでhooksを自動生成します。

それぞれのhookを別のコンポーネントで定義し、それらのコンポーネントをトップページから呼び出します。以降はキャッシュの値に注目していきます。

以下に使用するソースコードを添付しますのでご確認ください。

ソースコード

nameを取得するコンポーネントをRocketComponent.tsx、countryを取得するコンポーネントをRocketComponent2.tsxとします。

Home.tsx
import { RocketComponent } from "./RocketComponent";
import { RocketComponent2 } from "./RocketComponent2";

export const HomePage = () => {
  return (
    <div>
      <h1>Home</h1>
      <RocketComponent />
      <RocketComponent2 />
    </div>
  );
};
RocketComponent.tsx
import { useRocketsQuery } from "@/gql";

export const RocketComponent = () => {
  const { data, loading, refetch: fetch } = useRocketsQuery();
  if (loading) {
    return <div>loading...</div>;
  }

  return (
    <div>
      <button onClick={() => fetch()}>fetch</button>
      {data && (
	<div>
	  {data.launches?.slice(0, 10).map((launch) => (
	    <div key={launch?.id}>
	      <h3>{launch?.rocket?.rocket?.name}</h3>
	    </div>
	  ))}
	</div>
      )}
    </div>
  );
};
RocketComponent2.tsx
import { useRockets2Query } from "@/gql";

export const RocketComponent2 = () => {
  const { data, loading, refetch: fetch } = useRockets2Query();
  if (loading) {
    return <div>loading...</div>;
  }
  return (
    <div>
      <button onClick={() => fetch()}>fetch</button>
      {data && (
	<div>
	  {data.launches?.slice(0, 10).map((launch) => (
	    <div key={launch?.id}>
	      <h3>{launch?.rocket?.rocket?.country}</h3>
	    </div>
	  ))}
	</div>
      )}
    </div>
  );
};

キャッシュの変遷

1. ローディング中&データフェッチ完了後

ローディング画面

初回データフェッチ完了後画面

2. 初回クエリ後のキャッシュ状態

2つのクエリを同時に実行しているので、結果が表示される順番は不規則になります。

ここでRocket.graphql(nameを取得するクエリ)の結果が先に返ってきたとしましょう。Apollo Client Devtoolsを使ってキャッシュの中身を見て行きます。

launchesidフィールドを持っていたため子となるオブジェクトが参照に置き換わっています。参照IDはLaunch:5eb87cd9ffd86e000604b32aときちんと<__typename>:<idまたは_id>という法則に従っていることがわかります。

Launchのキャッシュ結果

しかしRocketオブジェクトへの参照を辿るとcountryフィールドしか保存されていません。つまりあとから取得したクエリのフィールドのみがキャッシュされている状態です。

Rocketのキャッシュ結果

3. nameを再フェッチ

そして上記の状態からnameを取得するクエリを再フェッチしてみます。すると今度はcountryフィールドの値がキャッシュから削除されnameフィールドの値がキャッシュされました。

これ以降は最も新しくフェッチしたフィールドの値がキャッシュされている結果となりました。

再フェッチ後のRocketのキャッシュ結果

なぜこのような挙動をするのか

直感的には再フェッチしたあとのRocketオブジェクトは以下のようになっていると期待するのではないでしょうか。

期待するキャッシュ構造
rocket {
  __typename:"Rocket"
  name:"Falcon 1",  // nameもある
  country:"Republic of the Marshall Islands"  // countryもある
}

しかし実際は先ほど説明した通り、nameフィールドかcountryフィールドのどちらかしかキャッシュされていません。この挙動についてはApollo Clientのスタッフエンジニアである@phryneasさんによってissue内で説明されています。

それによると理由は以下になります。

例えば以下の2つのクエリを同時に投げたとします。

2つのクエリ
# authorを取得するクエリ
query {
  post {
    id
    comments {
      author
    }
  }
}
  
# textを取得するクエリ
query {
  post {
    id
    comments {
      text
    }
  }
}

そしてそれぞれのクエリの結果が以下であったと仮定しましょう。

2つのクエリの結果
// authorを取得するクエリの結果
{ post: { id: 1, comments: [ { author: "Tim" }, { author: "Lilly" } ] } }

// textを取得するクエリの結果
{ post: { id: 1, comments: [ { text: "I approve of this" }, { text: "I don't agree" } ] } }

commentオブジェクトにはIDがないので以下のように結果をマージしたとします。

キャッシュに保存する値(結果をマージ)
{ post: { id: 1, comments: [ { author: "Tim", text: "I approve of this" }, { author: "Lilly", text: "I don't agree" } ] } }

一見良さそうに思えますが、"Tim""I approve of this"をコメントしたということは保証できないため"Lilly"がこのコメントをしたという以下のようなパターンも考えられます。

以下の結果もあり得る
{ post: { id: 1, comments: [ { author: "Lilly", text: "I approve of this" }, { author: "Sandy", text: "I don't agree" } ] } }

そのためApollo Clientはオブジェクトを特定できない場合に以前のキャッシュを削除し、置き換えます。
これがIDがないと正しくキャッシュされない理由です。この挙動により2つのクエリからキャッシュを上書きし続け、無限ループを起こす危険性もあります。

IDフィールドを追加する

では今まで使用してきたクエリにidフィールドを追加しましょう。

IDフィールドを追加したクエリ
### Rocket.graphql ###
query Rockets {
  launches {
    id
    rocket {
      rocket {
	id  # idを追加
        name
      }
    }
  }
}

### Rocket2.graphql ###
query Rockets2 {
  launches {
    id
    rocket {
      rocket {
      	id  # idを追加
        country
      }
    }
  }
}

同じようにクエリを実行してキャッシュの状態を確認していきましょう。

RocketオブジェクトにIDを追加したため正規化され、参照に置き換わっています!🎉
そして参照先のキャッシュにはnameフィールドとcountryフィールドの両方の値が保存されています!

参照に置き換わったRocketオブジェクト

正規化されたRocketオブジェクト

スキーマにIDがない場合

Apollo Clientはデフォルトでidまたは_idフィールドをユニークな識別子と認識します。
しかしスキーマによってはidフィールドがない場合もあります。例えば以下のようなUserなどが考えられます。

user {
  __typename:"User",
  userId:"hogehoge",
  name:"fujiyamaorange",
  email:"sample@example.com"
}

この場合Userはid_idフィールドも持たないので期待した通りにキャッシュされません。

typePoliciesでIDを設定する

こういった場合に対応できるようにApollo ClientはtypePoliciesプロパティでどのフィールドをユニークな識別子として扱うかを設定できます。

例えば、上記のUserの場合以下のように設定できます。

const cache = new InMemoryCache({
  typePolicies: {
    User: {
      keyFields: ["userId"],
    },
  },
});

これでUserオブジェクトに関してはuserIdがあれば意図した通りのキャッシュ動作します!🎉

まとめ

Apollo ClientのキャッシュについてIDフィールドという観点から見てきました。
「常にIDフィールドはつける、なければ設定する」 ということが誰かの役に立てば幸いです。

参考

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

https://www.apollographql.com/docs/react/caching/cache-configuration#customizing-cache-ids

https://github.com/apollographql/apollo-client/issues/5762

https://github.com/apollographql/apollo-client/issues/10992

Discussion