最近はgraphql-ruby + Reactで業務や個人開発を行うことが多いです。
サーバサイドのスタックは多少変われど、フロントのReact + TypeScript + Apollo Clientという構成は使いまわしてやってます。
tsとgraphqlの相性が良かったり、apolloのhooksが非常に便利だったりと、書いてて健やかな気持ちになるんですよね。

今回はそんなフロントエンドの肝、Apollo Clientについて解説していきます。特にキャッシュあたり。

内容

  • graphql-ruby + Reactのアプリケーションを使ってApolloのキャッシュ周りを触る(graphql-rubyは記事内では触れません)
  • セットアップや実装の話というよりはApolloの便利ポイント・挙動の解説メインです。
  • graphql-codegenで自動生成されたコードをベースに実装しています https://graphql-code-generator.com/

Apollo

Apollo Client is a comprehensive state management library for JavaScript that enables you to manage both local and remote data with GraphQL. Use it to fetch, cache, and modify application data, all while automatically updating your UI. https://www.apollographql.com/docs/react/

自身のことを「状態管理のライブラリです」と謳っています。
加えて、データのやりとりを行いつつUIを自動的に更新することもできると。

hooksも用意されておりとても便利です。かなりスッキリと、状態に応じたUIの出し分けを行うことができます。
公式で紹介されているコードを軽く眺めるだけでもその威力が伝わるかと思います。

function Feed() {
  const { loading, error, data } = useQuery(GET_DOGS);
  if (error) return <Error />;
  if (loading || !data) return <Fetching />;

  return <DogList dogs={data.dogs} />;
}

https://www.apollographql.com/docs/react/why-apollo/#declarative-data-fetching より

useQuery から返されている値はすべてリアクティブなもので、それぞれ

  • ロード状況
  • エラーの有無・内容
  • リクエストの結果

がリアルタイムに更新されます。

そして、「状態管理のライブラリです」と言っているだけあってキャッシュ機能(+ローカルステート管理)が強力です。 見ていきましょう。

実例交えてキャッシュの更新紹介

「ログインなしでTODOを登録できるアプリ」を題材に紹介したいと思います。
画面イメージはこんな感じ。
左のメニューにTODO一覧があり、メイン欄のフォームからTODOの登録ができます。

default

まず前提として、Apolloはリクエストを投げた結果返ってきたレスポンスを正規化してキャッシュしておいてくれます。
TODOをいくつか入れて、キャッシュの様子を見てみましょう。

最初に、一覧取得クエリについて。
クエリとレスポンスはこんな感じになっています。

query todos {
  todos {
    id
    content
    __typename
  }
}

indexresponse

開発ツールを使うと、Apolloの中身が見れます。

https://chrome.google.com/webstore/detail/apollo-client-developer-t/jdkknkkbebbapilgoeccciglkfbmbnfm

cached-index

Apolloは各Objectが持つ __typenameid をもとに、アイテムを一意に扱います。 レスポンスに含まれる各TODOはそれぞれ “Todo” という __typename と、サーバ側でつけられたuuidを id として持っていますね。 Apolloがこのレスポンスを見て正規化を行いキャッシュした結果が二枚目の画像でした。

新規追加の場合

では、新規TODOを追加してみます。

mutation実装

  const [mutate] = useAddTodoMutation({
    variables: {
      input: {
        content: content,
      },
    },
    update(cache, result) {
      cache.modify({
        fields: {
          todos(existing = []) {
            const newTodo = result.data?.addTodo?.todo

            const writeRef = cache.writeQuery({
              query: TodoDocument,
              data: { todo: newTodo },
            })

            return [...existing, writeRef]
          },
        },
      })
    },
  })
mutation addTodo($input: AddTodoInput!) {
  addTodo(input: $input) {
    todo {
      id
      content
      __typename
    }
    __typename
  }
}

引数で受け取るAddTodoInputはこちらです。

export type AddTodoInput = {
  content?: Maybe<Scalars['String']>;
  clientMutationId?: Maybe<Scalars['String']>;
};

graphql-rubyが吐き出すschemaを元にgraphql-codegenで自動生成した型です。
まあTODOのコンテンツだけ渡しておけばOKです。

今回の話で肝になるのはここです。

    update(cache, result) {
      cache.modify({
        fields: {
          todos(existing = []) {
            const newTodo = result.data?.addTodo?.todo

            const writeRef = cache.writeQuery({
              query: TodoDocument,
              data: { todo: newTodo },
            })

            return [...existing, writeRef]
          },
        },
      })
    },

(writeQueryで使ってるTodoDocumentはこれ)

export const TodoDocument = gql`
  query todo($id: ID!) {
    todo(id: $id) {
      id
      content
    }
  }
`

何をしているかというと、
明示的に、mutationの結果を用いてキャッシュの更新を行っています。
updateの場合は、Apolloがキャッシュ内の各オブジェクトの __typenameid を見て、レスポンスの内容と一致するものを自動で更新してくれます。
が、追加・削除の場合はApollo任せにすることができないので、自分でコードを書いておく必要があります。

キャッシュ更新の方法は、

  • todo一覧取得クエリを再度投げる
  • キャッシュを直接更新する

こちらのふたつがあるのですが、今回はキャッシュ更新の方で実装しています。

updateオプションの引数で現状のキャッシュとレスポンスのオブジェクトが受け取れるので、これを利用してキャッシュを更新します。
詳しくはこのあたりをご覧ください。
https://www.apollographql.com/docs/react/data/mutations/#updating-the-cache-after-a-mutation

これによって、戻り値を元に一覧のキャッシュが更新され、同時にTODO一覧表示も自動で更新されます。

add-todo

added-todos

added-with-index

(撮りなおしたのでidに齟齬がありますが補完していただければ幸いです)

更新の場合

次に既存TODOの更新です。更新の際はキャッシュの更新も勝手にやってくれるので楽ですね。

更新処理はこれしか書いてません。追加mutationと比べると、こちらにはレスポンスを受けとってキャッシュを触る処理がありませんね。

  const [mutate] = useUpdateTodoMutation({
    variables: {
      input: {
        id: id,
        content: content,
      },
    },
  })

先ほど追加したTODOを更新するためにこれを実行した結果はこうなります。

updated

updated-apollo

updated-apollo-show

updated-display

ROOT_QUERYにあるtodosの[3]はidが 85aa469b~ となっているTODOへの参照になっています。また、レスポンスに含まれているTODOも同じidを持っていますね。
キャッシュのTODOを見てみると、ちゃんとcontentが入力値である updated todo に更新されていることがわかります。
同時に画面の表示もそのように変更されています。

まとめ

というように、Apolloは自動でうまいことキャッシュを扱い、少ない労力で表示を同期させてくれます。

キャッシュやレスポンスからidが欠けていたり、名前が微妙に違ったりすると想定通りに更新してくれず沼にハマることもありますが、使いこなせばこんなに便利なものはないです。

また一覧の部分で今回紹介しなかった、refetchで一覧キャッシュを更新する方法ですが、
コード量が少なく済む反面リクエストが飛ぶので、キャッシュ直接更新の方がユーザー体験は良いのではないかと思います。

refetchについてはこちら https://www.apollographql.com/docs/react/data/queries/#refetching

以上、Apolloとキャッシュについてでした。