Apollo のキャッシュ思想を俯瞰する:正規化・型ポリシー・部分データの取り扱い
GraphQL クライアントライブラリである Apollo Client は、その強力なキャッシュ機構によって開発者から高い評価を得ています。単にデータを保存するだけでなく、正規化によってデータの一貫性を保ち、型ポリシーで柔軟なデータ管理を実現し、部分データを効率的に扱うことができるのです。
本記事では、Apollo Client のキャッシュ思想の核心部分である「正規化」「型ポリシー」「部分データの取り扱い」について、初心者の方にもわかりやすく解説していきますね。これらの仕組みを理解することで、より効率的で保守性の高い GraphQL アプリケーションを構築できるようになるでしょう。
背景
GraphQL とキャッシュの関係性
GraphQL は REST API とは異なり、クライアントが必要なデータを自由に指定できる柔軟性を持っています。しかし、この柔軟性がキャッシュの複雑さを生み出します。
REST API では URL がキャッシュのキーとなりますが、GraphQL では同じエンドポイントに対して異なるクエリを送信するため、単純な URL ベースのキャッシュ戦略は使えません。この課題を解決するために、Apollo Client は独自のキャッシュ思想を確立しました。
以下の図は、GraphQL クエリがどのようにキャッシュと連携するかを示しています。
mermaidflowchart TB
client["クライアント<br/>アプリケーション"] -->|GraphQLクエリ| apollo["Apollo Client"]
apollo -->|キャッシュ確認| cache["InMemoryCache"]
cache -->|キャッシュヒット| apollo
cache -->|キャッシュミス| server["GraphQLサーバー"]
server -->|レスポンス| cache
cache -->|正規化して保存| store["正規化<br/>ストレージ"]
apollo -->|結果を返却| client
この図から、Apollo Client がキャッシュを第一にチェックし、必要な場合のみサーバーにリクエストを送る仕組みがわかりますね。
InMemoryCache の誕生
Apollo Client の中核となる InMemoryCache は、メモリ上にデータを保持する高速なキャッシュストレージです。単なるキーバリューストアではなく、以下の特徴を持っています。
| # | 特徴 | 説明 |
|---|---|---|
| 1 | 正規化 | データを ID ベースで一元管理 |
| 2 | 型安全性 | TypeScript との親和性が高い |
| 3 | リアクティブ | データ変更時に自動的に UI を更新 |
| 4 | 柔軟性 | 型ポリシーによるカスタマイズが可能 |
これらの特徴により、大規模なアプリケーションでも効率的にデータを管理できるようになりました。
課題
データの重複と一貫性の問題
GraphQL では、異なるクエリで同じデータを取得する場合があります。例えば、ユーザー一覧とユーザー詳細で同じユーザー情報を取得する際、キャッシュに重複してデータが保存されてしまう可能性があるのです。
以下のような状況を考えてみましょう。
typescript// ユーザー一覧を取得するクエリ
const GET_USERS = gql`
query GetUsers {
users {
id
name
email
}
}
`;
typescript// 特定のユーザー詳細を取得するクエリ
const GET_USER_DETAIL = gql`
query GetUserDetail($id: ID!) {
user(id: $id) {
id
name
email
profile
createdAt
}
}
`;
この 2 つのクエリは、同じユーザーの id、name、email を取得しています。もしキャッシュが単純にクエリごとにデータを保存すると、以下の問題が発生します。
| # | 問題点 | 影響 |
|---|---|---|
| 1 | データの重複 | メモリの無駄遣い |
| 2 | 更新の不整合 | 片方だけ古いデータが残る |
| 3 | 予測困難な挙動 | デバッグが難しくなる |
以下の図は、正規化されていないキャッシュでの問題を示しています。
mermaidflowchart LR
query1["getUsersクエリ"] -->|結果を保存| cache1["キャッシュ領域1<br/>{`id:1, name:太郎`}"]
query2["getUserDetailクエリ"] -->|結果を保存| cache2["キャッシュ領域2<br/>{`id:1, name:太郎`}"]
update["nameを花子に更新"] -->|更新| cache1
cache1 -.->|不整合発生| inconsistent["領域1:花子<br/>領域2:太郎"]
同じユーザーのデータが別々の場所に保存されているため、片方を更新してももう片方には反映されず、データの不整合が発生してしまうのです。
部分的なデータの扱い
GraphQL の強みは、必要なフィールドだけを取得できることですが、これがキャッシュにとっては難題となります。
あるクエリでは name と email だけを取得し、別のクエリでは name、email、profile を取得した場合、キャッシュはこれらをどう扱うべきでしょうか。
typescript// 最小限のフィールドを取得
const MINIMAL_USER = gql`
query MinimalUser($id: ID!) {
user(id: $id) {
id
name
}
}
`;
typescript// より多くのフィールドを取得
const FULL_USER = gql`
query FullUser($id: ID!) {
user(id: $id) {
id
name
email
profile
avatar
}
}
`;
この場合、以下のような課題が生じます。
- キャッシュに部分的なデータしかない時、クエリを満たせるか判断できるか
- 新しいデータで既存のキャッシュをどう更新するか
- 欠けているフィールドをどう表現するか
型ごとの異なる要件
アプリケーションでは、データの型によって異なるキャッシュ戦略が必要になることがあります。
typescript// ユーザー型:頻繁に更新される
type User = {
id: string;
name: string;
lastLogin: Date;
};
typescript// 設定型:ほとんど変更されない
type Settings = {
theme: string;
language: string;
};
型によって、以下のような要件が異なるのです。
| # | データ型 | キャッシュ要件 |
|---|---|---|
| 1 | ユーザー情報 | ID で識別、頻繁に更新 |
| 2 | 記事一覧 | ページネーション対応 |
| 3 | 設定情報 | シングルトン、永続化 |
| 4 | 一時データ | 短期間のみ保持 |
これらの多様な要件に対応するため、柔軟なキャッシュ設定が必要になりました。
解決策
正規化によるデータの一元管理
Apollo Client の最も重要な機能が「正規化」です。正規化とは、各オブジェクトを一意な ID で識別し、キャッシュ内で一箇所にのみ保存する仕組みですね。
正規化の基本概念
InMemoryCache は、デフォルトで以下のルールでオブジェクトを正規化します。
typescript// Apollo Client の初期化
import {
ApolloClient,
InMemoryCache,
} from '@apollo/client';
typescript// InMemoryCache のインスタンス作成
const cache = new InMemoryCache({
// 型ポリシーの設定(後述)
});
typescript// Apollo Client の設定
const client = new ApolloClient({
uri: 'https://api.example.com/graphql',
cache: cache,
});
Apollo Client は、取得したオブジェクトから __typename と id(または _id)を組み合わせてキャッシュキーを生成します。
以下の図は、正規化によってデータがどのように保存されるかを示しています。
mermaidflowchart TB
query["GraphQLクエリ結果"] -->|正規化処理| normalizer["正規化エンジン"]
normalizer -->|型名+IDで識別| cache["正規化キャッシュ"]
subgraph cache["正規化キャッシュストア"]
user1["User:1<br/>{`name:太郎,email:...`}"]
user2["User:2<br/>{`name:花子,email:...`}"]
post1["Post:101<br/>{`title:記事1,author:User:1`}"]
end
post1 -.->|参照| user1
この図から、各オブジェクトが一箇所に保存され、他のオブジェクトから参照される構造がわかります。
正規化の実装例
実際のコードで正規化がどのように機能するか見てみましょう。
typescript// GraphQL クエリの定義
const GET_POST_WITH_AUTHOR = gql`
query GetPostWithAuthor($postId: ID!) {
post(id: $postId) {
id
title
content
author {
id
name
email
}
}
}
`;
このクエリを実行すると、サーバーから以下のようなレスポンスが返ってきます。
json{
"data": {
"post": {
"__typename": "Post",
"id": "101",
"title": "Apollo Client 入門",
"content": "...",
"author": {
"__typename": "User",
"id": "1",
"name": "太郎",
"email": "taro@example.com"
}
}
}
}
Apollo Client は、このレスポンスを以下のように正規化してキャッシュに保存します。
typescript// 内部的なキャッシュの状態(イメージ)
{
"Post:101": {
"__typename": "Post",
"id": "101",
"title": "Apollo Client 入門",
"content": "...",
"author": {
"__ref": "User:1" // 参照として保存
}
},
"User:1": {
"__typename": "User",
"id": "1",
"name": "太郎",
"email": "taro@example.com"
}
}
author フィールドは、実際のデータではなく User:1 への参照として保存されることに注目してください。
正規化のメリット
正規化によって、以下のメリットが得られます。
typescript// 別のクエリでユーザー情報を更新
const UPDATE_USER_NAME = gql`
mutation UpdateUserName($id: ID!, $name: String!) {
updateUser(id: $id, name: $name) {
id
name
}
}
`;
typescript// ミューテーション実行後、キャッシュが自動更新される
const [updateUserName] = useMutation(UPDATE_USER_NAME);
// ユーザー名を更新
await updateUserName({
variables: { id: '1', name: '花子' },
});
typescript// この更新により、User:1 を参照しているすべての箇所が自動的に更新される
// - ユーザー一覧
// - 投稿の著者情報
// - コメントの投稿者情報
// など、User:1 を参照しているすべてのコンポーネントが再レンダリングされる
一箇所の更新が、そのデータを参照しているすべての場所に自動的に反映されるため、データの一貫性が保たれるのです。
型ポリシーによる柔軟なカスタマイズ
型ポリシーは、型ごとにキャッシュの振る舞いをカスタマイズできる強力な機能です。
keyFields のカスタマイズ
デフォルトの id や _id 以外のフィールドを識別子として使いたい場合、keyFields を設定します。
typescript// 複合キーを使用する型ポリシー
const cache = new InMemoryCache({
typePolicies: {
// Book 型のキャッシュキーを isbn で識別
Book: {
keyFields: ['isbn'],
},
},
});
typescript// 複数のフィールドを組み合わせた複合キー
const cache = new InMemoryCache({
typePolicies: {
// UserSession 型を userId と sessionId の組み合わせで識別
UserSession: {
keyFields: ['userId', 'sessionId'],
},
},
});
これにより、Book:978-4-12345-678-9 のようなキャッシュキーが生成されます。
フィールドポリシーによる計算フィールド
フィールドポリシーを使用すると、サーバーから取得していないフィールドをクライアント側で計算できます。
typescript// 計算フィールドの定義
const cache = new InMemoryCache({
typePolicies: {
Product: {
fields: {
// price と tax から totalPrice を計算
totalPrice: {
read(_, { readField }) {
const price = readField('price');
const tax = readField('tax');
return price + tax;
},
},
},
},
},
});
typescript// クエリでは totalPrice を取得していないが、
// キャッシュから読み取る際に自動計算される
const PRODUCT_QUERY = gql`
query GetProduct($id: ID!) {
product(id: $id) {
id
price
tax
# totalPrice は含まれていない
}
}
`;
typescript// コンポーネント内で使用
function ProductDisplay({ productId }) {
const { data } = useQuery(PRODUCT_QUERY, {
variables: { id: productId },
});
// totalPrice が自動的に計算される
return <div>合計: {data.product.totalPrice}円</div>;
}
サーバーから取得していないデータでも、既存のフィールドから自動計算できるため、クエリを簡潔に保てますね。
マージ戦略のカスタマイズ
配列フィールドの更新方法も、型ポリシーでカスタマイズできます。
typescript// ページネーション対応のマージ戦略
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
posts: {
// 既存の配列と新しい配列をマージ
keyArgs: ['category'], // category ごとに別のキャッシュ
merge(existing = [], incoming, { args }) {
// offset ベースのページネーション
const merged = existing.slice(0);
const offset = args?.offset || 0;
for (let i = 0; i < incoming.length; i++) {
merged[offset + i] = incoming[i];
}
return merged;
},
},
},
},
},
});
typescript// ページ追加時の動作
// 1ページ目取得: posts(offset: 0, limit: 10)
// → キャッシュ: [post1, post2, ..., post10]
// 2ページ目取得: posts(offset: 10, limit: 10)
// → キャッシュ: [post1, ..., post10, post11, ..., post20]
この設定により、ページネーション時に新しいデータが既存のデータに正しく追加されます。
以下の図は、型ポリシーによるカスタマイズの全体像を示しています。
mermaidflowchart TB
typepolicy["型ポリシー設定"]
typepolicy --> keyfields["keyFields<br/>識別子のカスタマイズ"]
typepolicy --> fieldpolicy["フィールドポリシー<br/>計算・マージ戦略"]
typepolicy --> cacheoption["その他オプション<br/>TTL・永続化など"]
keyfields --> example1["例:ISBN,複合キー"]
fieldpolicy --> example2["例:合計金額計算<br/>配列マージ"]
cacheoption --> example3["例:キャッシュ有効期限"]
型ポリシーを活用することで、アプリケーション固有の要件に柔軟に対応できるようになります。
部分データの取り扱い戦略
Apollo Client は、部分的なデータも効率的に扱えるよう設計されています。
フィールドの存在チェック
キャッシュから読み取る際、Apollo Client は要求されたすべてのフィールドが存在するかチェックします。
typescript// 最初に最小限のデータを取得
const { data: minimalData } = useQuery(
gql`
query MinimalUser($id: ID!) {
user(id: $id) {
id
name
}
}
`,
{ variables: { id: '1' } }
);
typescript// キャッシュの状態
// User:1 { id: "1", name: "太郎" }
typescript// 後から追加のフィールドを要求
const { data: fullData } = useQuery(
gql`
query FullUser($id: ID!) {
user(id: $id) {
id
name
email // キャッシュに存在しない
}
}
`,
{ variables: { id: '1' } }
);
email フィールドがキャッシュに存在しないため、Apollo Client は自動的にサーバーにリクエストを送信します。
fetchPolicy によるキャッシュ戦略
fetchPolicy オプションで、キャッシュとネットワークのバランスを調整できます。
typescript// キャッシュ優先(デフォルト)
const { data } = useQuery(QUERY, {
fetchPolicy: 'cache-first',
// キャッシュにデータがあればそれを返す
// なければネットワークリクエスト
});
typescript// ネットワーク優先
const { data } = useQuery(QUERY, {
fetchPolicy: 'network-only',
// 常にサーバーから最新データを取得
// キャッシュは更新されるが読み取りには使わない
});
typescript// キャッシュのみ
const { data } = useQuery(QUERY, {
fetchPolicy: 'cache-only',
// ネットワークリクエストを送らない
// キャッシュになければエラー
});
typescript// キャッシュ and ネットワーク
const { data } = useQuery(QUERY, {
fetchPolicy: 'cache-and-network',
// キャッシュの結果を即座に返し、
// 同時にネットワークリクエストも送る
});
それぞれの fetchPolicy の特徴を表にまとめました。
| # | fetchPolicy | キャッシュ読取 | ネットワーク要求 | 用途 |
|---|---|---|---|---|
| 1 | cache-first | ○ | キャッシュミス時のみ | 通常のデータ取得 |
| 2 | cache-only | ○ | × | オフライン対応 |
| 3 | network-only | × | ○ | 常に最新データが必要 |
| 4 | cache-and-network | ○ | ○ | 即座に表示+更新 |
| 5 | no-cache | × | ○ | キャッシュ不要 |
部分データの警告制御
部分的なデータしかない場合でも、アプリケーションを動作させたいケースがあります。
typescript// returnPartialData で部分データを許可
const { data, loading } = useQuery(QUERY, {
returnPartialData: true,
// キャッシュに一部のフィールドしかなくても
// 取得可能なデータを返す
});
typescript// コンポーネント内での使用例
function UserProfile({ userId }) {
const { data, loading } = useQuery(GET_USER_QUERY, {
variables: { id: userId },
returnPartialData: true,
});
// データが部分的でも表示できる
return (
<div>
<h2>{data?.user?.name || '読み込み中...'}</h2>
{data?.user?.email && <p>{data.user.email}</p>}
{loading && <Spinner />}
</div>
);
}
この設定により、ユーザー体験を向上させながら、段階的にデータを表示できます。
フラグメントによる再利用
GraphQL のフラグメントを使用すると、共通のフィールドセットを定義して再利用できます。
typescript// ユーザー情報の基本フラグメント
const USER_BASIC_FRAGMENT = gql`
fragment UserBasic on User {
id
name
avatar
}
`;
typescript// ユーザー情報の詳細フラグメント
const USER_DETAIL_FRAGMENT = gql`
fragment UserDetail on User {
...UserBasic
email
profile
createdAt
}
${USER_BASIC_FRAGMENT}
`;
typescript// クエリでフラグメントを使用
const GET_USER_BASIC = gql`
query GetUserBasic($id: ID!) {
user(id: $id) {
...UserBasic
}
}
${USER_BASIC_FRAGMENT}
`;
const GET_USER_DETAIL = gql`
query GetUserDetail($id: ID!) {
user(id: $id) {
...UserDetail
}
}
${USER_DETAIL_FRAGMENT}
`;
フラグメントを使用することで、キャッシュの構造が統一され、部分データの管理が容易になります。
具体例
実践的なブログアプリケーション
Apollo Client のキャッシュ機能を活用したブログアプリケーションの実装例を見ていきましょう。
プロジェクトのセットアップ
まず、必要なパッケージをインストールします。
bash# プロジェクトの作成と必要パッケージのインストール
yarn create next-app blog-app --typescript
cd blog-app
yarn add @apollo/client graphql
Apollo Client の初期化と型ポリシー設定
アプリケーション全体で使用する Apollo Client を設定します。
typescript// lib/apolloClient.ts
import {
ApolloClient,
InMemoryCache,
HttpLink,
} from '@apollo/client';
typescript// HTTP リンクの作成
const httpLink = new HttpLink({
uri:
process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT ||
'http://localhost:4000/graphql',
credentials: 'include', // Cookie を含める
});
typescript// 型ポリシーを含むキャッシュの設定
const cache = new InMemoryCache({
typePolicies: {
// Post 型のポリシー
Post: {
keyFields: ['id'],
fields: {
// いいね数とコメント数から人気度を計算
popularity: {
read(_, { readField }) {
const likes = readField('likesCount') || 0;
const comments =
readField('commentsCount') || 0;
return (
(likes as number) * 2 +
(comments as number) * 3
);
},
},
},
},
// User 型のポリシー
User: {
keyFields: ['id'],
},
// Query 型のポリシー(ルートクエリ)
Query: {
fields: {
// 投稿一覧のページネーション対応
posts: {
keyArgs: ['category', 'sortBy'],
merge(existing = [], incoming, { args }) {
const offset = args?.offset || 0;
const merged = existing.slice(0);
for (let i = 0; i < incoming.length; i++) {
merged[offset + i] = incoming[i];
}
return merged;
},
},
},
},
},
});
typescript// Apollo Client のインスタンス作成
export const apolloClient = new ApolloClient({
link: httpLink,
cache: cache,
defaultOptions: {
watchQuery: {
fetchPolicy: 'cache-and-network',
errorPolicy: 'all',
},
query: {
fetchPolicy: 'cache-first',
errorPolicy: 'all',
},
},
});
この設定により、投稿のページネーションやカスタム計算フィールドが適切に動作するようになります。
GraphQL フラグメントの定義
再利用可能なフラグメントを定義します。
typescript// lib/fragments.ts
import { gql } from '@apollo/client';
typescript// ユーザー情報の基本フラグメント
export const USER_BASIC_FRAGMENT = gql`
fragment UserBasic on User {
id
name
avatar
}
`;
typescript// 投稿情報の基本フラグメント
export const POST_BASIC_FRAGMENT = gql`
fragment PostBasic on Post {
id
title
excerpt
publishedAt
likesCount
commentsCount
author {
...UserBasic
}
}
${USER_BASIC_FRAGMENT}
`;
typescript// 投稿情報の詳細フラグメント
export const POST_DETAIL_FRAGMENT = gql`
fragment PostDetail on Post {
...PostBasic
content
tags
updatedAt
}
${POST_BASIC_FRAGMENT}
`;
フラグメントを使用することで、クエリ間でフィールドセットが統一され、キャッシュの効率が向上します。
投稿一覧コンポーネント
投稿一覧を表示し、ページネーションに対応したコンポーネントを実装します。
typescript// components/PostList.tsx
import { useQuery } from '@apollo/client';
import { gql } from '@apollo/client';
import { POST_BASIC_FRAGMENT } from '../lib/fragments';
typescript// 投稿一覧取得クエリ
const GET_POSTS = gql`
query GetPosts(
$offset: Int
$limit: Int
$category: String
) {
posts(
offset: $offset
limit: $limit
category: $category
) {
...PostBasic
}
}
${POST_BASIC_FRAGMENT}
`;
typescript// 投稿一覧コンポーネント
export function PostList({ category }: { category?: string }) {
const [offset, setOffset] = React.useState(0);
const limit = 10;
const { data, loading, fetchMore } = useQuery(GET_POSTS, {
variables: { offset, limit, category },
notifyOnNetworkStatusChange: true,
});
typescript// さらに読み込むハンドラー
const handleLoadMore = () => {
fetchMore({
variables: {
offset: offset + limit,
},
});
setOffset(offset + limit);
};
typescript if (loading && !data) {
return <div>読み込み中...</div>;
}
return (
<div>
{data?.posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
<button onClick={handleLoadMore} disabled={loading}>
{loading ? '読み込み中...' : 'さらに読み込む'}
</button>
</div>
);
}
このコンポーネントでは、fetchMore を使用してページネーションを実装しています。型ポリシーの merge 関数により、新しいデータが既存のリストに正しく追加されるのです。
投稿詳細コンポーネント
個別の投稿を表示するコンポーネントを実装します。
typescript// components/PostDetail.tsx
import { useQuery } from '@apollo/client';
import { gql } from '@apollo/client';
import { POST_DETAIL_FRAGMENT } from '../lib/fragments';
typescript// 投稿詳細取得クエリ
const GET_POST = gql`
query GetPost($id: ID!) {
post(id: $id) {
...PostDetail
# popularity はキャッシュで計算されるため、
# サーバーに要求する必要はない
}
}
${POST_DETAIL_FRAGMENT}
`;
typescript// 投稿詳細コンポーネント
export function PostDetail({ postId }: { postId: string }) {
const { data, loading, error } = useQuery(GET_POST, {
variables: { id: postId },
});
if (loading) return <div>読み込み中...</div>;
if (error) return <div>エラーが発生しました</div>;
const { post } = data;
typescript return (
<article>
<h1>{post.title}</h1>
<div>
<img src={post.author.avatar} alt={post.author.name} />
<span>{post.author.name}</span>
<time>{new Date(post.publishedAt).toLocaleDateString()}</time>
</div>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
<div>
<span>いいね: {post.likesCount}</span>
<span>コメント: {post.commentsCount}</span>
</div>
</article>
);
}
投稿一覧で既にキャッシュされているデータがあれば、詳細ページは即座に表示されます。
いいね機能の実装
ミューテーションとキャッシュ更新の実装例です。
typescript// components/LikeButton.tsx
import { useMutation } from '@apollo/client';
import { gql } from '@apollo/client';
typescript// いいねミューテーション
const LIKE_POST = gql`
mutation LikePost($postId: ID!) {
likePost(id: $postId) {
id
likesCount
isLikedByMe
}
}
`;
typescript// いいねボタンコンポーネント
export function LikeButton({ postId, initialLikes, initialIsLiked }: {
postId: string;
initialLikes: number;
initialIsLiked: boolean;
}) {
const [likePost, { loading }] = useMutation(LIKE_POST, {
variables: { postId },
// 楽観的 UI 更新
optimisticResponse: {
likePost: {
__typename: 'Post',
id: postId,
likesCount: initialIsLiked ? initialLikes - 1 : initialLikes + 1,
isLikedByMe: !initialIsLiked,
},
},
});
typescript const handleClick = async () => {
try {
await likePost();
} catch (error) {
console.error('いいねに失敗しました', error);
}
};
return (
<button onClick={handleClick} disabled={loading}>
{initialIsLiked ? 'いいね済み' : 'いいね'} ({initialLikes})
</button>
);
}
optimisticResponse を使用することで、サーバーのレスポンスを待たずに UI を更新し、ユーザー体験を向上させています。
以下の図は、ミューテーション実行時のキャッシュ更新フローを示しています。
mermaidsequenceDiagram
participant UI as UI コンポーネント
participant Apollo as Apollo Client
participant Cache as InMemoryCache
participant Server as GraphQL サーバー
UI->>Apollo: likePost ミューテーション実行
Apollo->>Cache: 楽観的レスポンスで即座に更新
Cache->>UI: 更新通知(likesCount増加)
UI->>UI: 即座に再レンダリング
Apollo->>Server: ミューテーションリクエスト送信
Server->>Apollo: 実際のレスポンス返却
Apollo->>Cache: 実際のデータでキャッシュ更新
Cache->>UI: 更新通知(必要なら再レンダリング)
楽観的更新により、ユーザーは待ち時間なくアクションの結果を確認できるのです。
キャッシュの読み書き
キャッシュを直接操作する高度な例です。
typescript// utils/cacheHelpers.ts
import { apolloClient } from '../lib/apolloClient';
import { gql } from '@apollo/client';
typescript// キャッシュから投稿を読み取る関数
export function readPostFromCache(postId: string) {
return apolloClient.cache.readFragment({
id: `Post:${postId}`,
fragment: gql`
fragment ReadPost on Post {
id
title
likesCount
commentsCount
}
`,
});
}
typescript// キャッシュの投稿を更新する関数
export function updatePostInCache(
postId: string,
updates: Partial<Post>
) {
apolloClient.cache.writeFragment({
id: `Post:${postId}`,
fragment: gql`
fragment UpdatePost on Post {
id
title
likesCount
commentsCount
}
`,
data: {
__typename: 'Post',
id: postId,
...updates,
},
});
}
typescript// 使用例:コメント追加時にコメント数を増やす
export function incrementCommentsCount(postId: string) {
const post = readPostFromCache(postId);
if (post) {
updatePostInCache(postId, {
commentsCount: post.commentsCount + 1,
});
}
}
キャッシュを直接操作することで、サーバーへのリクエストなしでローカル状態を更新できます。
パフォーマンス最適化のポイント
実際のアプリケーションでキャッシュを効果的に活用するためのポイントをまとめます。
クエリの最適化
typescript// 悪い例:必要以上のデータを取得
const BAD_QUERY = gql`
query GetAllUserData {
users {
id
name
email
profile
posts {
id
title
content
comments {
id
text
author { ... }
}
}
}
}
`;
typescript// 良い例:必要なデータのみを取得
const GOOD_QUERY = gql`
query GetUserList {
users {
id
name
avatar
}
}
`;
必要なフィールドのみを取得することで、ネットワーク転送量とキャッシュサイズを削減できます。
バッチリクエストの活用
typescript// Apollo Link Batch HTTP を使用してリクエストをバッチ化
import { BatchHttpLink } from '@apollo/client/link/batch-http';
const batchLink = new BatchHttpLink({
uri: 'http://localhost:4000/graphql',
batchMax: 10, // 最大10リクエストをバッチ化
batchInterval: 20, // 20ms以内のリクエストをまとめる
});
複数のクエリを 1 つの HTTP リクエストにまとめることで、ネットワークのオーバーヘッドを削減できます。
キャッシュの永続化
typescript// Apollo Client の永続化(apollo3-cache-persist を使用)
import { persistCache } from 'apollo3-cache-persist';
typescriptasync function setupApollo() {
const cache = new InMemoryCache();
// キャッシュを LocalStorage に永続化
await persistCache({
cache,
storage: window.localStorage,
maxSize: 1048576, // 1MB
});
return new ApolloClient({
cache,
// ... その他の設定
});
}
typescript// アプリケーション起動時に永続化されたキャッシュを復元
setupApollo().then((client) => {
// Apollo Provider で使用
});
キャッシュを永続化することで、ページリロード後も即座にデータを表示できます。
以下の表は、各最適化手法の効果をまとめたものです。
| # | 最適化手法 | 効果 | 実装難易度 |
|---|---|---|---|
| 1 | 必要最小限のフィールド取得 | 転送量削減 | ★ |
| 2 | フラグメントの活用 | コード再利用性向上 | ★ |
| 3 | バッチリクエスト | リクエスト数削減 | ★★ |
| 4 | 楽観的更新 | UX 向上 | ★★ |
| 5 | キャッシュ永続化 | 初期表示高速化 | ★★★ |
| 6 | 型ポリシーによる計算フィールド | サーバー負荷軽減 | ★★★ |
まとめ
Apollo Client のキャッシュ思想について、正規化・型ポリシー・部分データの取り扱いという 3 つの核心的な要素を詳しく見てきました。
正規化によって、データを一箇所で一元管理し、更新の一貫性を保証できます。型ポリシーを使えば、アプリケーション固有の要件に合わせてキャッシュの動作を柔軟にカスタマイズできるのです。そして、部分データを適切に扱うことで、効率的なデータ取得と快適なユーザー体験を両立できますね。
これらの仕組みを理解し、適切に活用することで、以下のようなメリットが得られるでしょう。
- サーバーへのリクエスト数を削減し、パフォーマンスを向上
- データの一貫性を保ち、バグを減らす
- ユーザー体験を向上させる楽観的更新の実装
- メンテナンス性の高いコードベースの構築
Apollo Client のキャッシュは単なるデータストアではなく、GraphQL アプリケーションの中核となる設計思想なのです。この記事で紹介した概念と実装例を参考に、ぜひ実際のプロジェクトでキャッシュ機能を活用してみてください。
関連リンク
articleApollo のキャッシュ思想を俯瞰する:正規化・型ポリシー・部分データの取り扱い
articleApollo GraphOS を用いた安全なリリース運用:Schema Checks/Launch Darkly 的な段階公開
articleApollo で“キャッシュが反映されない”を 5 分で直す:ID/ポリシー/write 系の落とし穴
articleApollo で BFF(Backend for Frontend)最適化:画面別スキーマと Contract Graph の併用
articleApollo Router と Node 製 Gateway の実測比較:スループット/遅延/運用性
articleApollo キャッシュ操作チートシート:`cache.modify`/`writeQuery`/`readFragment` 早見表
articleAstro でレイアウト崩れが起きる原因を特定する手順:スロット/スコープ/スタイル隔離
articleESLint × Vitest/Playwright:テスト環境のグローバルと型を正しく設定
articleDify を Kubernetes にデプロイ:Helm とスケーリング設計の実践
articleApollo のキャッシュ思想を俯瞰する:正規化・型ポリシー・部分データの取り扱い
articleZod で“境界”を守る設計思想:IO バリデーションと型推論の二刀流
articleYarn vs npm vs pnpm 徹底比較:速度・メモリ・ディスク・再現性を実測
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来