Graphql&ApolloClientでキャッシュ活用して宣言型UIをよりパワフルにする

宣言型UIを使っているとコンポーネント間の情報の伝達に苦労します
いわゆるプロップスバケツリレーになってしまうことが往々にしてありますが、状態管理ライブラリを多用するのも処理の流れを追いづらくなり、Reactなどの単方向データフローが台無しになってしまいます

[単方向データフローの参考](https://zenn.dev/yumemi_inc/articles/react-coupling-loose#%E5%AF%86%E7%B5%90%E5%90%88%E3%83%BB%E7%96%8E%E7%B5%90%E5%90%88%E3%81%AE%E5%89%8D%E3%81%AB-react-%E3%81%AE%E3%83%AB%E3%83%BC%E3%83%AB%E3%82%92%E7%9F%A5%E3%82%8D%E3%81%86)

そこで今回はGraphQLとApollo Client を用いたアプローチを紹介します

# **Apollo Client とは**

[Apollo Client](https://www.apollographql.com/docs/react/) とは、アプリケーションのデータを取得、キャッシュ、変更しながら、UI を自動的に更新することができ、また**ローカル**とリモートの両方のデータを GraphQL で管理できる**包括的な状態管理ライブラリ**です

つまりただのGraphQLというAPIを扱うためのライブラリではなく、取得したデータをローカルのメモリで管理できる状態管理ライブラリでもあるのです

流れとしては「ローカルでキャッシュ検索→ヒットしなければサーバーに問い合わせ→レスポンスからキャッシュの作成、画面への反映」となります

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

については先行記事がたくさんあるのでそちらを参考にしてください

[Apollo Clientのキャッシュの仕組みとローカルの状態管理について](https://zenn.dev/buyselltech/articles/b64935ea7d6fee#apollo-client-のキャッシュの仕組み)

一応こちらでも簡単に説明しますと、Apollo Client はクエリ or ミューテーションのレスポンスをみて一意のIDを作成し正規化されたデータをメモリキャッシュに保存します
userとpostスキーマを用いて見てみます

schema

```graphql
type User {
  id: ID!
  name: String!
  posts: [Post!]!
}

type Post {
  id: ID!
  title: String!
  content: String!
  author: User!
}

type Query {
  user(id: ID!): User
  post(id: ID!): Post
  posts: [Post!]!
}
```

query

```graphql
query GetUserWithPosts($userId: ID!) {
  user(id: $userId) {
    id
    name
    posts {
      id
      title
      content
    }
  }
}
```

mutation

```graphql
mutation renameUser($userId: ID!) {
  user(id: $userId) {
    id
    name
  }
}
```

### Apollo Clientのキャッシュフロー

1. **クエリの実行**:
Apollo Clientは、サーバーに対してクエリを実行し、結果を取得します
2. **正規化とキャッシュの保存**:
Apollo Clientは、取得したデータを正規化し、キャッシュに保存します
正規化の際に、各エンティティ（UserやPost）を個別のキャッシュエントリとして保存します
    
    例として、以下のようなデータがサーバーから返ってきたとします：
    
    ```json
    {
      "data": {
        "user": {
          "id": "1",
          "name": "John Doe",
          "__typename": "User"
          "posts": [
            {
              "id": "101",
              "title": "Post Title 1",
              "content": "Content of the first post",
              "__typename": "Post"
            },
            {
              "id": "102",
              "title": "Post Title 2",
              "content": "Content of the second post",
              "__typename": "Post"
            }
          ]
        }
      }
    }
    ```
    
    Apollo Clientはこのデータを以下のように正規化します：
    
    ```json
    {
      "ROOT_QUERY": { // queryのキャッシュ
        "user({\"id\":\"1\"})": { "__ref": "User:1" }
      },
      "User:1": { // __typename + id で生成される正規化されたキャッシュ
        "id": "1",
        "name": "John Doe",
        "posts": [
          {"__ref":"Post:1"},
          {"__ref":"Post:2"}
        ]
      },
      "Post:101": {
        "id": "101",
        "title": "Post Title 1",
        "content": "Content of the first post",
        "author": "User:1"
      },
      "Post:102": {
        "id": "102",
        "title": "Post Title 2",
        "content": "Content of the second post",
        "author": "User:1"
      }
    }
    ```
    
3. **キャッシュの利用**:
クエリが実行されると、Apollo Clientは`ROOT_QUERY`を確認します
クエリの結果がキャッシュされていれば、そのキャッシュが利用されます
4. キャッシュの更新
[2. ]で生成されたキャッシュのIDに一致するデータに対してミューテーションが実行された場合、ミューテーションのレスポンスをもとにキャッシュを更新します
ミューテーションの結果で以下のレスポンスが返ってきたとします
    
    ```json
    {
      "data": {
        "user": {
          "id": "1",
          "name": "Hendy",
          "__typename": "User"
    	}
    }
    ```
    
    下記のようにキャッシュが更新されます
    
    ```json
    {
      "ROOT_QUERY": {
        "user({\"id\":\"1\"})": { "__ref": "User:1" }
      },
      "User:1": {
        "id": "1",
        "name": "Hendy", // mutationのresponseによって更新された
        "posts": ["Post:101", "Post:102"]
      },
      "Post:101": {
        "id": "101",
        "title": "Post Title 1",
        "content": "Content of the first post",
        "author": "User:1"
      },
      "Post:102": {
        "id": "102",
        "title": "Post Title 2",
        "content": "Content of the second post",
        "author": "User:1"
      }
    }
    ```
    
    つまりミューテーションでは変更をApollo Clientに通知するために変更後のデータをIDとセットで返す必要があります

以上がざっくりとしたApollo Clientのキャッシュの流れです

## 実装してみる

先ほどのスキーマを用いてキャッシュが変更された場合にどのようにして画面に反映されるのかをみてみましょう
今回はReactを使って実装してみます

`UserDisplay` :IDに紐づいたユーザーを表示するコンポーネント

```tsx
import { gql, useQuery } from "@apollo/client";
import { useState } from "react";
import SelectUser from "./SelectUser";

const GET_USER = gql`
  query GetUser($id: ID!) {
    user(id: $id) {
      id
      name
    }
  }
`;

function UserDisplay() {
  const [userId, setUserId] = useState("1");
  const { loading, error, data } = useQuery(GET_USER, {
    variables: { id: userId },
    skip: !userId,
  });

  return (
    <>
      <h2>UserDisplay</h2>
	    <select value={userId} onChange={(event) => setUserId(event.target.value)}>
	      <option value="1">1</option>
	      <option value="2">2</option>
	      <option value="3">3</option>
	      <option value="4">4</option>
	      <option value="5">5</option>
	    </select>
      {loading && <p>Loading...</p>}
      {error && <p>Error: {error.message}</p>}
      {data && data.user && (
        <div>
          id: {data.user.id}, name: {data.user.name}
        </div>
      )}
    </>
  );
}

export default UserDisplay;
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3309753/9a066e2a-c896-ae97-1953-681bafe13f56.png)

`UserRenameForm` :指定されたIDのユーザの名前を変更するコンポーネント

```tsx
import { gql, useMutation } from "@apollo/client";
import { useState } from "react";
import SelectUser from "./SelectUser";

const RENAME_USER = gql`
  mutation RenameUser($id: ID!, $name: String!) {
    renameUser(id: $id, name: $name) {
      id
      name
    }
  }
`;

function UserRenameForm() {
  const [userId, setUserId] = useState("1");
  const [userName, setUserName] = useState("");
  const [renameUser, { data, loading, error }] = useMutation(RENAME_USER);

  const handleSubmit = (event: React.ChangeEvent<HTMLFormElement>) => {
    event.preventDefault();
    renameUser({ variables: { id: userId, name: userName } });
  };

  return (
    <>
      <h2>User Rename</h2>
      <div>
        <form onSubmit={handleSubmit}>
          <SelectUser userId={userId} onChange={setUserId} />
          <div>
            <label>
              New Name:
              <input
                type="text"
                value={userName}
                onChange={(e) => setUserName(e.target.value)}
                required
              />
            </label>
          </div>
          <button type="submit">Rename User</button>
        </form>
        {loading && <p>Loading...</p>}
        {error && <p>Error: {error.message}</p>}
        {data && data.renameUser && (
          <p>
            User {data.renameUser.id} renamed to {data.renameUser.name}
          </p>
        )}
      </div>
    </>
  );
}

export default UserRenameForm;
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3309753/69ca918b-0a56-0cc6-2b2a-ad63d0498517.png)

サーバーはメモリ上のデータを返す・変更するだけのシンプルなものです

<details><summary>server.js</summary>

```tsx
const { gql } = require("graphql-tag");
const express = require("express");
const { ApolloServer } = require("apollo-server-express");

const typeDefs = gql`
  type Query {
    user(id: ID!): User
  }

  type User {
    id: ID!
    name: String!
  }

  type Mutation {
    renameUser(id: ID!, name: String!): User
  }
`;

const users = {
  1: { id: "1", name: "Alice" },
  2: { id: "2", name: "Bob" },
  3: { id: "3", name: "Charlie" },
};

const getUser = async (id) => {
  if (!users[Number(id)]) {
    throw new Error("User not found");
  }
  await new Promise((resolve) => setTimeout(resolve, 500));
  return users[Number(id)];
};

const resolvers = {
  Query: {
    user: async (_, { id }) => {
      return await getUser(id);
    },
  },
  Mutation: {
    renameUser: async (_, { id, name }) => {
      const user = users[Number(id)]
      if (!user) {
        throw new Error("User not found");
      }
      user.name = name;
      return user;
    },
  },
};

async function startServer() {
  const server = new ApolloServer({
    typeDefs,
    resolvers,
  });
  await server.start();
  const app = express();
  server.applyMiddleware({ app });
  app.listen(4000, () =>
    console.log(`🚀 Server ready at http://localhost:4000${server.graphqlPath}`)
  );
}

startServer();
```

</details>

実装ができたので動きをみていきましょう
`UserDisplay` でidを2に変更してみます

![user_display.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3309753/8e906c73-c67d-73ea-c327-3c72f13c4c52.gif)


サーバー側でtimeout 500secを置いているのでloadingが走りました
もう一度ユーザー2を表示してみます

![user_display_cached.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3309753/c830c184-4c3c-d261-a9d4-6f06ef932310.gif)


2回目の表示はローディングが走りませんでした
つまりキャッシュからの取得に成功しています

次に`UserDiplay` でユーザー1を表示したまま、`UserRenameForm` でユーザー1の名前を変更します

![user_rename.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3309753/f0815ac0-6045-43cd-6277-07d15d6d87b4.gif)


ミューテーションでデータを更新し、変更したユーザーの情報を返しているので正規化されたキャッシュデータをApollo Clientが更新してくれました

こうすることでPropsで二つのコンポーネントにidを渡さなくてもAPIを通してデータを更新し、再レンダリングまでして最新の情報を画面に出力できました
これがパワーです💪<ﾊﾟﾜｰ

ユーザーは各UIをGraphQL APIを通して欲しいデータを取得し表示するだけでいいのです
とはいえキャッシュされないパターンは色々あるので↓を参考にしてみてください

[Apollo Client を基礎から理解する（QueryとMutationのhooksの使い方編）](https://zenn.dev/furharu/articles/26557d977b1b8c#クエリのキャッシュが自動更新されない処理)

偉そうにGraphQLすごいぞと言ってますが`react-query` 使えばREST APIでも同じようなことができます
ただし正規化されてキャッシュが管理されないのでデータ更新時にユーザーがハンドリングする場面が多くなるイメージです

<details><summary>UserDisplay</summary>

```tsx
import { useState } from "react";
import { useQuery } from "react-query";
import SelectUser from "../SelectUser";
import axios from "axios";
import useErrorHandler from "../hooks/useErrorHandler";

const fetchUser = async (id: string) => {
  const { data } = await axios.get(`/api/users/${id}`);
  return data;
};

function UserRESTDisplay() {
  const [userId, setUserId] = useState("1");
  const { isLoading, error, data } = useQuery(
    ["user", userId],
    () => fetchUser(userId),
    {
      enabled: !!userId,
    }
  );
  const { extractErrorMessage } = useErrorHandler();

  return (
    <>
      <h2>UserDisplay</h2>
      <SelectUser userId={userId} onChange={setUserId} />
      {isLoading && <p>Loading...</p>}
      {error && <p>Error: {extractErrorMessage(error)}</p>}
      {data && (
        <div>
          id: {data.id}, name: {data.name}
        </div>
      )}
    </>
  );
}

export default UserRESTDisplay;
```

</details>

<details><summary>UserRenameForm</summary>

```tsx
import { useState } from "react";
import { useMutation, useQueryClient } from "react-query";
import SelectUser from "../SelectUser";
import axios from "axios";
import useErrorHandler from "../hooks/useErrorHandler";

const renameUser = async ({ id, name }: { id: string; name: string }) => {
  const { data } = await axios.put(`/api/users/${id}`, { name });
  return data;
};

function UserRESTRenameForm() {
  const [userId, setUserId] = useState("");
  const [userName, setUserName] = useState("");
  const queryClient = useQueryClient();
  const { extractErrorMessage } = useErrorHandler();

  const mutation = useMutation(renameUser, {
    onSuccess: (data) => {
      queryClient.setQueryData(["user", userId], data);
    },
  });

  const handleSubmit = (event: React.ChangeEvent<HTMLFormElement>) => {
    event.preventDefault();
    mutation.mutate({ id: userId, name: userName });
  };

  return (
    <>
      <h2>User Rename</h2>
      <div>
        <form onSubmit={handleSubmit}>
          <SelectUser userId={userId} onChange={setUserId} />
          <div>
            <label>
              New Name:
              <input
                type="text"
                value={userName}
                onChange={(e) => setUserName(e.target.value)}
                required
              />
            </label>
          </div>
          <button type="submit">Rename User</button>
        </form>
        {mutation.isLoading && <p>Loading...</p>}
        {mutation.isError && (
          <p>Error: {extractErrorMessage(mutation.error)}</p>
        )}
        {mutation.isSuccess && (
          <p>
            User {mutation.data.id} renamed to {mutation.data.name}
          </p>
        )}
      </div>
    </>
  );
}

export default UserRESTRenameForm;
```

</details>

## まとめ

GraphQL と Apollo Client を使ってデータ取得と状態管理をいっぺんに任せてしまえるのはとても強力です
これにより状態管理をあまり意識せずにviewとstateを管理することができます
開発者はデータストアからUIに必要なものを取得して表現するだけでいいのです
もちろん再利用性も高くなります
しかしGraphQLは学習コストも高いので、損益分岐点をよく見分けることが重要そうだなと思いました

今回のソースは↓
https://github.com/sasakitimaru/graphql-cache/tree/main