バイセル Tech Blog

バイセル Tech Blogは株式会社BuySellTechnologiesのエンジニア達が知見・発見を共有する技術ブログです。

Apollo Client のキャッシュの仕組みとローカルの状態管理について

はじめに

テクノロジー戦略本部の早瀬です。

現在携わっているプロジェクトの技術選定で GraphQL を使うことになり、 GraphQL クライアントとして Apollo Client を採用することになりました。 最初は GraphQL をクライアントサイドで便利に使えるようにしてくれるものくらいの認識で、クライアント側の状態管理には別途 Redux とか入れるのかなと思っていたのですが、調査の過程でたまたま Apollo Client は Redux を置き換えるとの記事を見かけたので Apollo Client のキャッシュの仕組みと状態管理について少し調べてみました。

この記事では下記のことについて解説します。

  • Apollo Client とは
  • Apollo Client のキャッシュの仕組み
  • Reactive variables を利用したローカルの状態管理について

かなり内容がもりもりになってしまったのですが、最後まで読んでもらえると嬉しいです!

Apollo Client とは

公式によると Apollo Client とは、アプリケーションのデータを取得、キャッシュ、変更しながら、UI を自動的に更新することができ、またローカルとリモートの両方のデータを GraphQL で管理できる包括的な状態管理ライブラリです。

つまり、自分が思っていた「GraphQL をクライアント側で便利に使えるようにしてくれるもの」というのは単なる機能の一つであり、Apollo Client 自体は状態管理ライブラリということです。

後ほど詳しく解説しますが、Apollo Client を使用することで GraphQL API を簡単に操作できるようになるのはもちろん、クライアント側の状態管理を行うことができ、そのデータに対して GraphQL でアクセスすることができるようになります!

Apollo Client のキャッシュの仕組み

キャッシュの概要と扱い方について

Apollo Client では初期化時のオプションに InMemoryCache を渡すことで、サーバーへ送った GraphQL クエリの結果を自動で正規化してキャッシュに保存してくれるようになります。

import { ApolloClient, InMemoryCache } from "@apollo/client";

const cache = new InMemoryCache();

const client = new ApolloClient({
  cache: cache,
  uri: "http://localhost:3000/",
});

デフォルトの挙動ではコンポーネントからクエリが発行された場合、ApolloClient はまずキャッシュを見に行きます。 そしてキャッシュに必要なデータが全てある場合はサーバーへのリクエストは行わずキャッシュからデータを取得し、キャッシュに必要なデータが少しでも足りない場合はサーバーへリクエストを送り、レスポンスの内容をキャッシュに保存します。

注意が必要なのがサーバー側で何かしらデータの更新があったとしても、キャッシュにないデータを要求しない限りはキャッシュ内のデータをコンポーネントへ返却し続けるので、サーバー側へはリクエストが送られないのでサーバー側とキャッシュのデータに齟齬が生まれる可能性があるということです。 キャッシュの古いデータを表示するリスクを負いたくない場合などは、Fetch Policyというオプションを設定することでキャッシュの扱い方をアプリ全体、クエリごとに設定することができます。

上記の挙動を下記の様な2つのクエリの例に解説します。

type Task {
  id: Int!
  title: String!
  content: String!
}

query FetchAllTasks1 {
  tasks {
    id
    title
    content
  }
}

query FetchAllTasks2 {
  tasks {
    id
    title
  }
}

FetchAllTasks1→FetchAllTasks2 の順で実行された場合

Apollo Client はFetchAllTasks1クエリを受け取るがキャッシュが存在しないのでサーバーに対してリクエストし、レスポンスを下記の様なキャッシュとして保存します。

{
  "ROOT_QUERY": {
    "__typename": "Query",
    "tasks": [{ "__ref": "Task:1" }, { "__ref": "Task:2" }]
  },
  " Task:1": {
    "id": 1,
    "__typename": "Task",
    "title": "タスク1",
    "content": "タスク1の内容です"
  },
  " Task:2": {
    "id": 2,
    "__typename": "Task",
    "title": "タスク2",
    "content": "タスク2の内容です"
  }
}

その後FetchAllTasks2クエリが実行された場合、要求されたデータは全てキャッシュ内に存在するので Apollo Client はサーバーへリクエストは送らず、キャッシュからデータを取得しコンポーネントへ返却します。

FetchAllTasks2→FetchAllTasks1 の順で実行された場合

上記の場合同様に Apollo Client はFetchAllTasks2クエリを受け取るがキャッシュが存在しないのでサーバーに対してリクエストし、レスポンスを下記の様なキャッシュとして保存します。

{
  "ROOT_QUERY": {
    "__typename": "Query",
    "tasks": [{ "__ref": "Task:1" }, { "__ref": "Task:2" }]
  },
  "tasks:1": {
    "id": 1,
    "__typename": "Task",
    "title": "タスク1"
  },
  "tasks:2": {
    "id": 2,
    "__typename": "Task",
    "title": "タスク2"
  }
}

その後FetchAllTasks1クエリが実行された場合、要求されたデータの一部はキャッシュ内に存在します。 ただFetchAllTasks1クエリで要求しているcontentフィールドがキャッシュ内には存在しないので、この場合は Apollo Client はサーバーへ再度リクエストを送り、レスポンスの内容でキャッシュを更新した後、コンポーネントへデータを返却します。

キャッシュ更新時の再レンダリング

もう一点重要なことが、キャッシュされたデータをクエリで取得してる場合、そのコンポーネントはキャッシュの変更があった場合その変更を検知して再レンダリングを行います。

下記のようなTaskListコンポーネントを例に考えます。

import React from "react";
import { useQuery, useMutation, gql } from "@apollo/client";

const FETCH_ALL_TASKS = gql`
  query FetchAllTasks {
    tasks {
      id
      title
      content
    }
  }
`;

const UPDATE_TASK = gql`
  mutation UpdateTask {
    update_task_by_pk(
      pk_columns: { id: 1 }
      _set: { title: "更新済みタスク1", content: "更新済みタスク1です" }
    ) {
      id
      title
      content
    }
  }
`;

export const TaskList: React.VFC = () => {
  const { data } = useQuery(FETCH_ALL_TASKS);
  const [mutation] = useMutation(UPDATE_TASK);

  return (
    <>
      <ul>
        {data?.tasks.map((task) => (
          <li key={task.id}>
            <p>{task.title}</p>
            <p>{task.content}</p>
          </li>
        ))}
      </ul>
      <button onClick={() => mutation()}>タスク更新</button>
    </>
  );
};

前述した内容を踏まえると、このコンポーネントの処理フローは下記のようになります。

  1. 初回レンダリング時にコンポーネントが Apollo Client に対してクエリを実行
  2. Apollo Client はクエリを受け、サーバーにリクエストを送る
  3. Apollo Client はレスポンスの内容をキャッシュに保存し、データをコンポーネントにデータを返す
  4. コンポーネントはデータを受け取り画面に描写する
  5. タスク更新ボタンをクリックしてミューテーションを実行
  6. Apollo Client が受け取ったミューテーションをサーバーへリクエストし、レスポンスに含まれる更新後の内容でキャッシュを更新する
  7. コンポーネント(厳密には useQuery でセットアップされる QueryData というクラス)はキャッシュが変更したことを検知して再レンダリングを行う
  8. 更新後のデータで画面を描写する

ここでは同じコンポーネント内でミューテーションを実行してキャッシュの内容を更新していますが、他のコンポーネントから何かしらの操作でキャッシュが更新された場合も再レンダリングが行われます。 キャッシュの変更を検知して再レンダリングするのは、クエリで取得してるキャッシュデータに対して更新があった場合のみです。FetchAllTasksで取得したデータに含まれないキャッシュが更新されたとしてもこのコンポーネントで再レンダリングは行われません。

また、後述しますがステップ 6 に関して Apollo Client がキャッシュを自動で更新してくれる場合とそうでない場合があるので注意が必要です。

正規化の仕組み

冒頭で Apollo Client はサーバーへ送った GraphQL クエリの結果を正規化してキャッシュに保存すると説明しました。 ではどの様に正規化を行っているのでしょうか?

Apollo Client の正規化の仕組みは次の3つのステップで説明できます!

  1. サーバーからのクエリの結果を個別のオブジェクトに分割する
  2. キャッシュが安定してエンティティを追跡できるように、各オブジェクトに論理的に一意な識別子を付ける
  3. オブジェクトをフラット化されたデータ構造(正規化されたアイテム)に格納する

上記でも例に挙げた FetchAllTasksクエリを例にしてこのステップを追っていきます。

Tasks リストを分割

上記で例に挙げたFetchAllTasksクエリを実行して、下記の様な tasks のリストがレスポンスが返って来たとします。

{
  "data": {
    "tasks": [
      {
        "id": 1,
        "title": "タスク1",
        "content": "タスク1の内容です",
        "__typename": "Task"
      },
      {
        "id": 2,
        "title": "タスク2",
        "content": "タスク2の内容です",
        "__typename": "Task"
      },
      {
        "id": 3,
        "title": "タスク3",
        "content": "タスク3の内容です",
        "__typename": "Task"
      }
    ]
  }
}

この配列のアイテムを個々のオブジェクトに分割します。

{
  "id": 1,
  "title": "タスク1",
  "content": "タスク1の内容です",
  "__typename": "Task"
}

{
  "id": 2,
  "title": "タスク2",
  "content": "タスク2の内容です",
  "__typename": "Task"
}

{
  "id": 3,
  "title": "タスク3",
  "content": "タスク3の内容です",
  "__typename": "Task"
}

各オブジェクトへ一意な識別子を割り当てる

分割した各オブジェクトを識別できるように一意の識別子を割り振ります。デフォルトではid,__typenameフィールドから識別子を作成していて、例えばid1__typenameTask場合はTask:1という識別子になります。 なので基本的に GraphQL API が返すデータにはidフィールドを持たせた方がいいのですが、どうしてもそれが難しい場合は InMemoryCache のオプションの keyFields で各オブジェクトを識別するためのフィールドを設定できます。

フラット化されたデータ構造へオブジェクトを格納

各アイテムが識別子を持つと、Apollo Client は固有の識別子とオブジェクトを、フラット化した JavaScript オブジェクトへ格納します。 この時にすでに同じ識別子が存在している場合は新しいデータで上書きします。

{
  "Task:1": {
    "id": 1,
    "__typename": "Task",
    "title": "タスク1",
    "content": "タスク1の内容です"
  },
  "Task:2": {
    "id": 2,
    "__typename": "Task",
    "title": "タスク2",
    "content": "タスク2の内容です"
  },
  "Task:3": {
    "id": 3,
    "__typename": "Task",
    "title": "タスク3",
    "content": "タスク3の内容です"
  },
  "ROOT_QUERY": {
    "__typename": "Query",
    "tasks": [
      { "__ref": "Task:1" },
      { "__ref": "Task:2" },
      { "__ref": "Task:3" }
    ]
  }
}

この Java Script オブジェクトこそがキャッシュの正体で、正規化された各アイテムをフラットに保存することで(ハッシュテーブルのように)固有の識別子でアクセスできるようになりデータの検索が非常に早くなります!

またFetchAllTasksクエリの結果は配列で順序を維持しなければいけないので、キャッシュはクエリとそれに渡された引数、結果を保存します。tasksクエリの中のデータは正規化された Tasks アイテムへの参照を識別子で持っていますが、これこそが正規化の仕組みで、これによりキャッシュのサイズをできる限り小さくしデータの重複を防いでいます!

キャッシュが自動更新される、されない処理

Apollo Client はサーバーからのレスポンスをキャッシュし正規化してくれますが、キャッシュを自動で更新される処理とされない処理があります。ここではざっくりとしか解説しないので詳しくは公式のブログをご覧ください。(上記の正規化の話も詳しく載っています)。

自動で更新される処理

  • GraphQL クエリ全般
  • 既存の単一のアイテムを更新するミューテーション
  • 変更されたアイテムのセット全体を返す一括更新のミューテーション

自動で更新されない処理

  • アプリケーション固有の副作用
    • レスポンスに含まれるデータとは関係ないデータを更新したい場合のこと
    • 例)ログアウトの処理後に、キャッシュを全て削除する
  • コレクションに対する追加、削除、順序変更する更新操作

コレクションに対しては自動的にキャッシュを更新できないので、ミューテーション後に更新したいコレクションがある場合にはrefetchQueriesを使用して再度サーバーへクエリをリクエストしてコレクション全体を再取得するか、ミューテーションのオプションにupdate 関数を渡して直接キャッシュを書き換えないといけないです。

下記はupdate関数を使用してキャッシュを直接書き換える処理の例です。

const [addTask] = useMutation(ADD_TASK, {
  // ミューテーション後に実行される処理
  update(cache, { data }) {
    const newTask = data?.add_task; // ミューテーションのレスポンス
    const existingTasks = cache.readQuery({
      query: FETCH_ALL_TASKS,
    });

    if (newTask && existingTasks) {
      // FETCH_ALL_TASKSのキャッシュに新規タスクを追加
      cache.writeQuery({
        query: FETCH_ALL_TASKS,
        data: { tasks: [...existingTasks?.tasks, newTask] },
      });
    }
  },
});

Reactive variables を利用したローカルの状態管理について

キャッシュの話がかなり長くなってしまいましたが、それを踏まえた上でローカルの状態管理の話をしていきたいと思います。 サーバーから取得したデータに関しては前述したようにキャッシュを活用することで、Apollo Client で管理することができますが、サーバーから取得したデータではなくローカルでのみで使用するデータを使いたい場合が出てくると思います。 それに対して Apollo Client はローカルの状態管理をする方法をいくつか提供してくれていますが、今回は Field policy と Reactive variables を利用した方法について紹介したいと思います。

Field policy とは

Field policy は InMemoryCache のオプションの一つで、Field policy を定義することで Apollo Client キャッシュの特定のフィールドの読み書きをカスタマイズすることができるようになります! 例えば下記のように Field policy を定義することで、Task の title フィールドの値は全て語尾に!がつけられた値が返されるようになります。

export const cache = new InMemoryCache({
  typePolicies: {
    Task: {
      fields: {
        title: {
          read(title) {
            return title + "!";
          },
        },
      },
    },
  },
});

既存のフィールドのカスタマイズはもちろん、スキーマに定義されていない新しいフィールドを定義することや、クエリタイプにフィールドを定義することでクライアントサイドでのみ使えるクエリを定義することもできます。

export const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        // ダミーのタスクの配列を返すクエリを定義
        // クライアントサイドでのみ使用可能
        dummyTasks: {
          read() {
            return [
              {
                id: 1,
                title: "dummy1",
                content: "dummy1",
              },
              {
                id: 2,
                title: "dummy2",
                content: "dummy2",
              },
            ];
          },
        },
      },
    },
    Task: {
      fields: {
        // ランダムなBool値を返すisCompletedを定義
        isCompleted: {
          read() {
            return Math.random() < 0.5;
          },
        },
      },
    },
  },
});

Field policy で定義のフィールドやクエリにアクセスする場合はclientディレクティブを使用します。 下記のFetchAllTasksのようにサーバー側のデータとローカルのデータを合わせて取得することもできます。

query FetchAllTasks {
  tasks {
    id
    title
    content
    isCompleted @client
  }
}

query FetchDummyTasks {
  dummyTasks @client {
    id
    title
    content
  }
}

Reactive variables とは

Reactive variables とはキャッシュの外にあるローカルな状態を扱うための変数です。 キャッシュから切り離されているのであらゆるタイプや構造のデータを格納することができ、アプリケーション内のどこからでもアクセスすることができます。

Reactive variables が更新されると、その変数に依存する全てのアクティブなクエリが更新されます。 つまり useReactiveVar フックや GraphQL クエリによって Reactive variables の値を読み取っているコンポーネントがあるとして、Reactive variables の値が更新された場合そのコンポーネントは変更を検知して再レンダリングを行います。

Reactive variables はmakeVar関数を使用して作成し、makeVar関数をが返す関数を引数なしで呼び出すことで Reactive variables の値を取得できます。

import { makeVar } from "@apollo/client";

// 空配列を初期値としてnumbersVarを作成
// makeVarの返り値はReactive variablesの値ではなく、
// Reactive variablesを読み書きするために呼び出す関数であることに注意
const numbersVar = makeVar([]);

// Output: []
console.log(numbersVar());

Reactive variables を更新するにはmakeVar関数で返される関数に引数を渡すことで更新できます。

import { makeVar } from "@apollo/client";

const numbersVar = makeVar([]);

// Output: []
console.log(numbersVar());

// [1, 2, 3]で更新
numbersVar([1, 2, 3]);

// Output: [1, 2, 3]
console.log(numbersVar());

タスク管理アプリでの実装例

Field policy と Reactive variables の説明をしましたがこれらを組み合わせることによって、ローカルの状態管理を行うことができます。 具体的には Field policy にクライアントサイドでのみで使用するクエリを定義し、そのクエリの返り値を Reactive variables にすることでローカルでのみ使用するデータの定義・GraphQL での操作が行えます。

タスク管理アプリで優先度の高いタスク一覧を取得したいとします。 その場合クエリは下記のようになります。

const FETCH_PRIORITY_TASKS = gql`
  query FetchPriorityTasks {
    fetchPriorityTasks @client {
      id
      title
    }
  }
`;

優先度の高いタスクのリストを格納する Reactive variables を初期化して、それを元に Field policy を定義します。

export const cache = new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        fetchPriorityTasks: {
          read() {
            return priorityTasksVar();
          },
        },
      },
    },
  },
});

// Reactive variables を空配列で初期化
export const priorityTasksVar = makeVar<Task[]>([]);

これでFetchPriorityTasksクエリが実行されるたびに、Reactive variables の値を返すようになります!

次に優先度の高いタスクをリストに追加するためのコンポーネントを作成します。

import { Task } from "../graphql/types";
import { priorityTasksVar } from "../graphql/apollo/cache";
import { PriorityTaskList } from "./PriorityTaskList";
import { gql, useQuery } from "@apollo/client";

const FETCH_ALL_TASKS = gql`
  query FetchAllTasks {
    tasks {
      id
      title
      content
    }
  }
`;

export const TaskList: React.VFC = () => {
  const { data } = useQuery(FETCH_ALL_TASKS);
  const addToPriorityTasks = (task: Task) => {
    const newPriorityTasks = [...priorityTasksVar(), task];

    // 重複を排除した配列でリアクティブ変数を更新
    priorityTasksVar(Array.from(new Set(newPriorityTasks)));
  };

  return (
    <div>
      <PriorityTaskList />
      <h1>タスク一覧</h1>
      <table cellSpacing={10}>
        <thead>
          <tr>
            <th>id</th>
            <th>タイトル</th>
            <th>内容</th>
            <th>操作</th>
          </tr>
        </thead>
        <tbody>
          {data?.tasks.map((task) => (
            <tr key={task.id}>
              <td>{task.id}</td>
              <td>{task.title}</td>
              <td>{task.content}</td>
              <td>
                <button onClick={() => addToPriorityTasks(task)}>
                  優先タスクに追加
                </button>
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
};

このコンポーネントではサーバーから取得したタスクの一覧を表示しています。 ボタンがクリックされるとpriorityTasksVarの値を更新し、ボタンに関連付けられたタスクを追加します。これによりFetchPriorityTasksクエリによりpriorityTasksVarの値を購読しているコンポーネントは通知を受けて、priorityTasksVarの値が変更されるたびに自動的に更新されます。

下記はFetchPriorityTasksクエリを使用するコンポーネントで、priorityTasksVarの値が変更されるたびに自動的に更新されます。

import { gql, useQuery } from "@apollo/client";

const FETCH_PRIORITY_TASKS = gql`
  query FetchPriorityTasks {
    fetchPriorityTasks @client {
      id
      title
    }
  }
`;

export const PriorityTaskList: React.VFC = () => {
  const { data } = useQuery(FETCH_PRIORITY_TASKS);

  return (
    <div>
      <h1>優先のタスク一覧</h1>
      <ul>
        {data?.fetchPriorityTasks.map((task) => (
          <li key={task.id}>{task.title}</li>
        ))}
      </ul>
    </div>
  );
};

このコンポーネントではクエリを実行する代わりにuseReactiveVarフックを使って Reactive variables を直接読み込むこともできますが、データの問い合わせに一貫性を持たせることができるのでクエリで取得する方が好ましいです。

実装したアプリケーションの挙動は下記のようになります f:id:bst-tech:20210825114844g:plain

ちゃんとpriorityTasksVarの変更を検知して再レンダリングされています!

まとめ

今回は Apollo Client のキャッシュとローカルの状態管理の方法について調査しましたが、キャッシュを含め Apollo Client がネットワーク周りの処理を全て受け持ってくれるのはとても楽だなと思ったのと、リモート・ローカルのどちらのデータにアクセスする場合も GraphQL で統一できるのでそこもいいと思いました! ただデータによってはサーバーとの整合性が重要な場合やそもそもキャッシュを使わない方がいい場合もあると思うので、そこに関してはキャッシュの適切な扱いを考えなければいけないです。

また、初めに自分の中にあった状態管理に別途 Redux とか入れなきゃいけないのかという疑問に関しても、個人的には必要ないという結論になりました。ネットワーク関連のデータは Apollo Client がキャッシュしてくれるので、残ったローカルの状態管理をどうするかという話になると思うのですが、そこに関しては Reactive variables を使ってもいいですしコンポーネントごとの local state で事足りるのでは、と思っています。

最後にバイセルテクノロジーズではエンジニアを募集しています! ご興味を持っていただけた方はぜひご応募ください!

hrmos.co