4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-07-15

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

単方向データフローの参考

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

Apollo Client とは

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

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

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

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

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

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

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

schema

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

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

mutation

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

Apollo Clientのキャッシュフロー

  1. クエリの実行:
    Apollo Clientは、サーバーに対してクエリを実行し、結果を取得します

  2. 正規化とキャッシュの保存:
    Apollo Clientは、取得したデータを正規化し、キャッシュに保存します
    正規化の際に、各エンティティ(UserやPost)を個別のキャッシュエントリとして保存します

    例として、以下のようなデータがサーバーから返ってきたとします:

    {
      "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はこのデータを以下のように正規化します:

    {
      "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に一致するデータに対してミューテーションが実行された場合、ミューテーションのレスポンスをもとにキャッシュを更新します
    ミューテーションの結果で以下のレスポンスが返ってきたとします

    {
      "data": {
        "user": {
          "id": "1",
          "name": "Hendy",
          "__typename": "User"
    	}
    }
    

    下記のようにキャッシュが更新されます

    {
      "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に紐づいたユーザーを表示するコンポーネント

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

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

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

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

server.js
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();

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

user_display.gif

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

user_display_cached.gif

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

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

user_rename.gif

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

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

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

Apollo Client を基礎から理解する(QueryとMutationのhooksの使い方編)

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

UserDisplay
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;
UserRenameForm
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;

まとめ

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

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

4
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?