宣言型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のキャッシュフロー
-
クエリの実行:
Apollo Clientは、サーバーに対してクエリを実行し、結果を取得します -
正規化とキャッシュの保存:
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" } }
-
キャッシュの利用:
クエリが実行されると、Apollo ClientはROOT_QUERY
を確認します
クエリの結果がキャッシュされていれば、そのキャッシュが利用されます -
キャッシュの更新
[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;
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;
サーバーはメモリ上のデータを返す・変更するだけのシンプルなものです
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に変更してみます
サーバー側でtimeout 500secを置いているのでloadingが走りました
もう一度ユーザー2を表示してみます
2回目の表示はローディングが走りませんでした
つまりキャッシュからの取得に成功しています
次にUserDiplay
でユーザー1を表示したまま、UserRenameForm
でユーザー1の名前を変更します
ミューテーションでデータを更新し、変更したユーザーの情報を返しているので正規化されたキャッシュデータを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