Apollo ClientではIDを必ずつけよう、ないなら設定しよう
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つのオブジェクトに分解して考えることができます。
- Personオブジェクト
"person": {
"__typename": "Person",
"id": "cGVvcGxlOjE=",
"name": "Luke Skywalker",
"homeworld": {
// Planetオブジェクト
}
}
- Planetオブジェクト
"homeworld": {
"__typename": "Planet",
"id": "cGxhbmV0czox",
"name": "Tatooine"
}
cache IDを生成
そして結果を各オブジェクトに分けることができれば次にcache ID
を生成します。
デフォルトでは、cache ID
= <__typenameの値>:<id(または_id)>
の形になります。
つまり今回の場合、それぞれ以下のようになります。
- Personオブジェクトのcache Id =
Person:cGVvcGxlOjE=
- 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を使います。
各パッケージのバージョンは以下です。
{
"dependencies": {
"@apollo/client": "^3.8.3",
"@graphql-codegen/cli": "^5.0.0",
...
}
}
IDフィールドがないスキーマ
まずはrocketオブジェクトのうちname
だけを取得するものとcountry
だけを取得するクエリを用意します。それぞれのrocketオブジェクトには 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
とします。
import { RocketComponent } from "./RocketComponent";
import { RocketComponent2 } from "./RocketComponent2";
export const HomePage = () => {
return (
<div>
<h1>Home</h1>
<RocketComponent />
<RocketComponent2 />
</div>
);
};
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>
);
};
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を使ってキャッシュの中身を見て行きます。
launches
にid
フィールドを持っていたため子となるオブジェクトが参照に置き換わっています。参照IDはLaunch:5eb87cd9ffd86e000604b32a
ときちんと<__typename>:<idまたは_id>
という法則に従っていることがわかります。
しかしRocketオブジェクトへの参照を辿るとcountry
フィールドしか保存されていません。つまりあとから取得したクエリのフィールドのみがキャッシュされている状態です。
3. nameを再フェッチ
そして上記の状態からnameを取得するクエリを再フェッチしてみます。すると今度はcountry
フィールドの値がキャッシュから削除されname
フィールドの値がキャッシュされました。
これ以降は最も新しくフェッチしたフィールドの値がキャッシュされている結果となりました。
なぜこのような挙動をするのか
直感的には再フェッチしたあとのRocket
オブジェクトは以下のようになっていると期待するのではないでしょうか。
rocket {
__typename:"Rocket"
name:"Falcon 1", // nameもある
country:"Republic of the Marshall Islands" // countryもある
}
しかし実際は先ほど説明した通り、name
フィールドかcountry
フィールドのどちらかしかキャッシュされていません。この挙動についてはApollo Clientのスタッフエンジニアである@phryneasさんによってissue内で説明されています。
それによると理由は以下になります。
例えば以下の2つのクエリを同時に投げたとします。
# authorを取得するクエリ
query {
post {
id
comments {
author
}
}
}
# textを取得するクエリ
query {
post {
id
comments {
text
}
}
}
そしてそれぞれのクエリの結果が以下であったと仮定しましょう。
// 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
フィールドを追加しましょう。
### 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
フィールドの両方の値が保存されています!
スキーマに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フィールドはつける、なければ設定する」 ということが誰かの役に立てば幸いです。
参考
Discussion