Apollo ClientのCache機構(fetch policy)を視覚的に理解する

この記事は [GLOBIS Advent Calendar 2021](https://qiita.com/advent-calendar/2021/globis) の12月9日の記事です。

## まえがき

こんにちは、こんばんわ。GLOBISでフロントエンドエンジニア / エンジニアリングマネージャーをしている [@shoota](https://qiita.com/shoota) です。
GLOBISでは React/TypeScriptでのFull SPAをコア技術のひとつとしてWebサービスの開発を進めており、その中のひとつの知見を改めて記事にまとめました。
今回は弊チームが開発しているReactアプリケーション[^1]でも利用している、Apollo Clientについてです。

Apollo Clientは非常に強力なCache機構をもっており、[fetch policy](https://www.apollographql.com/docs/react/data/queries/#setting-a-fetch-policy)を中心に、サンプルを用いながらその挙動を視覚的に理解できるようにしてみました。

SPAの開発初期には、クライアント側のCacheをどう取り扱うか迷ったり議論することもしばしばあり、それらを適切に扱っていくための開発コストもかかりがちかと思います。弊チームも例にもれず、Cacheをどう運用してSPAを作っていくか悩んだこともあり、これからのSPA開発の判断の一助となれば嬉しいなと思います。

_なおこちらの記事で動かしたサンプルは[こちら](https://github.com/shoota/react-playground)にあります。_

##環境構築

まずはSPAを動かす環境をつくります。

### SPA / Client

- TODOリストを表示するViewとContainerで構成する
- Apollo ClientのHook関数を[GraphQL code generator](https://www.graphql-code-generator.com/)で自動生成しておく
- ボタンを押すとgraphqlを実行してデータがReact propsに流れる
- webpack-dev-serverから`localhost:3035`でHTMLとSPAをserveする

表示するページのComponentはこんな感じにしました

```tsx
type Props = {
  isLoading?: boolean
  todos?: Todo[]
  getContent?: () => void
}

export const Todos: React.VFC<Props> = ({ todos, isLoading, getContent }) => {
  console.log(isLoading, new Date())
  return (
    <Card title="TODO List" style={{ margin: '5%' }}>
      <Button onClick={getContent}>データを取る</Button>
      <Divider />
      <List
        loading={isLoading}
        dataSource={todos}
        renderItem={({ id, contents, finished }) => (
          <List.Item key={id}>
            <Typography.Title level={3}>
              <Space>
                <CheckCircleTwoTone
                  twoToneColor={finished ? '#52c41a' : '#e3e3e3'}
                />
                {contents}
              </Space>
            </Typography.Title>
          </List.Item>
        )}
      />
    </Card>
  )
}
```

![Storybook.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/8585/dab89c8d-406a-b772-13c2-f8a28e9eb086.jpeg)



### GraphQL Server

- [Hasura](https://hasura.io/)を使用
  - 今回は配布されているDockerファイルを利用してlocalhostで実行
  - CORSで動くため `HASURA_GRAPHQL_CORS_DOMAIN: 'http://localhost:3035'`を指定しておく
![Browse_-_todos_-_Data___Hasura.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/8585/152bea3e-2a5d-1455-5fb0-216c8888e1bf.jpeg)
- TODOリストのテーブル定義
  - id: number (auto increment)
  - contents: text
  - finished: boolean

実行するクエリはこうなりました

```graphql
query Todos {
  todos {
    id
    contents
    finished
  }
}
```


## まずは単純なQueryで比較

ではまずQuery実行時の`fetchPolicy`オプションごとの挙動を見ていきます。
Queryに指定できる fetch policyは[こちら](https://www.apollographql.com/docs/react/data/queries/#supported-fetch-policies)のとおりです。

Cacheがどのように働いているかは、初回のデータフェッチがすんだ後に、再度取得したときにリクエストが飛んでいるか？とReactの再レンダーが何度起きているかを見ればわかります。
再レンダーの回数は、Presentational Componentの実行時にloading stateと現在時刻のログを仕込むことで測りました。
**データ取得時には、loading状態を挟んでいるので、「Loading中」と「データ取得後」で2回のレンダーが起きます。**

<blockquote class="twitter-tweet"><p lang="en" dir="ltr">Apollo Client / fetch policy <a href="https://t.co/dtEHurJbqJ">pic.twitter.com/dtEHurJbqJ</a></p>&mdash; 熱量 (@shoota) <a href="https://twitter.com/shoota/status/1468642647250472960?ref_src=twsrc%5Etfw">December 8, 2021</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>


|fetch policy|結果|
|:--|:--|
|cache-first (default)|cacheが存在しないときのみRequestを発行してCacheし、一度データがとれたらRequestは飛ばない。loading状態のレンダリングは初回のみにおこる。|
|network-only|常にRequestを発行してCacheするので、Query実行をするたびにloading状態のレンダリングが起きる|
|cache-and-network|`network-only`とRequestやレンダリング回数に差はない。データ取得を連打したときにちょっとだけloading状態の表示にばらつきがないように見える。|
|cache-only|Apollo cacheのみを参照するので、Requestが発行されず、loadingは常にfalse。|
|no-cache|`network-only`と挙動は同じ（Apolloがcache自体を放棄しているはずだが、見た目ではわからない）|
|standby|ドキュメントに書かれている通り、任意のタイミング(refetch/update)でデータ更新をするため、これを指定していない場合はRequestが発行されず、loadingがtrueになって停止する。|

注意したいのは`cache-and-network`の挙動です。cacheとRequestでデータが渡されるタイミングが2度あるので、「loading -> cacheデータを表示 -> loading -> 更新データを表示」 のように動いているとすれば、（矢印の数だけの再レンダーが発生するので）３回のレンダリングとなるように思えますが、実際には`network-only`と比べてレンダリング回数に差はありませんでした。これを次に視覚的に確認したいと思います。

## cacheの挙動の比較

`cache-first`は一度cacheされるとRequestしないため、HasuraのGUIから直接データを追加してもSPAに反映されないというところまで特徴がみえましたが、`network-only, cache-and-network, no-cache`は単純なQueryの比較では見た目の違いが捉えられず、挙動の差がいまいちわかりませんでした。
そこでつぎはGoogle Chromeのネットワークを `Slow 3G`にして、意図的にloading状態の表示を観察してみます。

<blockquote class="twitter-tweet"><p lang="en" dir="ltr">Slow 3G <a href="https://t.co/mWPGgqv7lQ">pic.twitter.com/mWPGgqv7lQ</a></p>&mdash; 熱量 (@shoota) <a href="https://twitter.com/shoota/status/1468649429314211842?ref_src=twsrc%5Etfw">December 8, 2021</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

[なんということでしょう](https://dic.nicovideo.jp/a/%E3%81%AA%E3%82%93%E3%81%A8%E3%81%84%E3%81%86%E3%81%93%E3%81%A8%E3%81%A7%E3%81%97%E3%82%87%E3%81%86)。`network-only`や`no-cache`の場合では、初回データ取得のあとにも「データが空になる」という状態をはさんで表示されるのに対して、`cache-and-network`はデータの取得が完了するまで(loadingがfalseになるまで)は初回で取得したcacheを渡し、データ取得が完了すると新たなデータとして確定してくれました。


## no-cacheの確認

最後に、no-cacheとnetwork-onlyを比較します。といっても、これらはApollo client内部にcacheがあるかどうかの違いだけなので、ドキュメントに書いてあることの確認になってしまいますね。この比較は単純なデータ取得のQueryではうまく比較できません。そこで恣意的ではありますが、「Requestで取得した後に、改めてcacheからデータをとりだす」ようにしてみます。これならno-cacheの場合はcacheがないので、何も表示されないはずです。


```tsx
  /* データ取得の返却値は使わずにreadQueryでcacheからわざわざ取り出す */
  useTodosQuery({ fetchPolicy }) // network-only or no-cache
  const data = client.readQuery({ query: TodosDocument })

  return <Todos todos={data?.todos} />
```
<blockquote class="twitter-tweet"><p lang="en" dir="ltr">read query <a href="https://t.co/K2J8opbGah">pic.twitter.com/K2J8opbGah</a></p>&mdash; 熱量 (@shoota) <a href="https://twitter.com/shoota/status/1468661178830565378?ref_src=twsrc%5Etfw">December 8, 2021</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

意図通り、`no-cache`の場合はreadQueryからデータを取り出しても取得することができていないことが確認できました。


## まとめ

Apollo clientのfetch policyについて、できるだけ視覚的に理解できるようなサンプルを作ってみました。
fetch policyは奥が深く、データのライフサイクルやリアルタイム性を考える上で重要な要素かと思います。例えば、リアルタイムでの更新が重要な場合は、`network-only`や `cache-and-network`を利用するのがよいでしょうし、検索系のQueryは`no-cache`にしてクライアントが持つデータの肥大化を防止していくのが求められるでしょう。

また今回は触れることができませんでしたが、データの更新(mutation)を実行した場合に、再度データを取り直したり（[refetch](https://www.apollographql.com/docs/react/data/mutations/#refetching-queries)）、GraphQLのFragmentに対応した[readFragment/writeFragment](https://www.apollographql.com/docs/react/caching/cache-interaction/#readfragment)、サーバーのレスポンスを待たずにcacheを更新する（[Optimistic mutation results](https://www.apollographql.com/docs/react/performance/optimistic-ui/))など、様々なデータのライフサイクルが用意されています。

開発初期の段階ではこれらを細かく制御をするのは大変ですが、SPAチューニングの大事な要素としてしっかりと理解して扱っていけると良いなと思いました。



[^1]: 弊チームは[GLOPLA LMS](https://glopla.globis.co.jp/)という社会人向け学習管理プラットフォームを開発しています。