Apollo Client のフィールドポリシー入門:read/merge で高度なキャッシュ制御を実装
Apollo Client を使ったアプリケーション開発では、GraphQL のクエリ結果をどのようにキャッシュするかが、パフォーマンスとユーザー体験を大きく左右します。デフォルトのキャッシュ動作でも多くのケースに対応できますが、ページネーションや計算フィールド、複雑なデータ構造を扱う場合には、より細かい制御が必要になるでしょう。
そこで本記事では、Apollo Client の フィールドポリシー(Field Policies) に焦点を当て、特に read 関数と merge 関数を活用した高度なキャッシュ制御の実装方法を、初心者の方にもわかりやすく解説します。実際のコード例を交えながら、段階的に理解を深めていきましょう。
背景
Apollo Client のキャッシュシステム
Apollo Client は、GraphQL クエリの結果を InMemoryCache に保存することで、同じデータへのリクエストを削減し、アプリケーションのパフォーマンスを向上させています。
このキャッシュシステムは、各オブジェクトを一意の識別子(通常は __typename と id の組み合わせ)で正規化し、フラットな構造で保存するのが特徴です。
以下の図は、Apollo Client の基本的なキャッシュフローを示しています。
mermaidflowchart TB
app["React アプリ"] -->|GraphQL クエリ| apollo["Apollo Client"]
apollo -->|キャッシュ確認| cache["InMemoryCache"]
cache -->|キャッシュヒット| apollo
cache -->|キャッシュミス| network["ネットワークリクエスト"]
network -->|レスポンス| apollo
apollo -->|データ正規化| cache
apollo -->|結果返却| app
Apollo Client は、クエリを実行する際にまずキャッシュを確認し、必要なデータがあればネットワークリクエストをスキップします。この仕組みにより、アプリケーションの応答速度が大幅に向上するのです。
フィールドポリシーとは
フィールドポリシー は、特定のフィールドに対してキャッシュの読み書き動作をカスタマイズできる仕組みです。Type Policies の一部として定義し、各フィールドごとに細かい制御が可能になります。
主な機能は以下の通りです。
| # | 機能 | 説明 |
|---|---|---|
| 1 | read 関数 | キャッシュからデータを読み取る際の処理をカスタマイズ |
| 2 | merge 関数 | データをキャッシュに書き込む際の処理をカスタマイズ |
| 3 | keyArgs | フィールドのキャッシュキーを決定する引数を指定 |
これらの機能を組み合わせることで、ページネーション、ソート、フィルタリングなど、さまざまなユースケースに対応できるでしょう。
課題
デフォルトキャッシュ動作の限界
Apollo Client のデフォルトキャッシュは非常に優秀ですが、以下のようなケースでは期待通りに動作しないことがあります。
1. ページネーションデータの蓄積
無限スクロールやページネーション機能を実装する際、新しいページのデータを取得しても、既存のデータが上書きされてしまうという問題があります。
typescript// ページネーションのクエリ例
const GET_POSTS = gql`
query GetPosts($offset: Int!, $limit: Int!) {
posts(offset: $offset, limit: $limit) {
id
title
content
}
}
`;
このクエリを offset: 0 と offset: 10 で実行すると、デフォルトでは後者が前者を上書きしてしまいます。本来は両方のデータを保持したいところですね。
2. 計算フィールドの実装
サーバーから送られてこないが、クライアント側で計算したい仮想フィールドを実装したい場合があります。例えば、商品の価格と税率から税込価格を算出するケースです。
typescript// サーバーから返却されるデータ
type Product = {
id: string;
name: string;
price: number;
// taxIncludedPrice はサーバーから返却されない
};
デフォルトのキャッシュでは、存在しないフィールドにアクセスすると undefined が返されてしまいます。
3. 異なる引数での同一フィールド
同じフィールドでも、引数が異なれば別のデータとして扱いたいケースがあります。
typescript// フィルター条件が異なるクエリ
const GET_USERS = gql`
query GetUsers($role: String!) {
users(role: $role) {
id
name
role
}
}
`;
role: "admin" と role: "user" では、異なる結果セットが返されるべきですが、デフォルトでは区別されません。
以下の図は、これらの課題が発生する状況を示しています。
mermaidflowchart LR
query1["クエリ1<br />offset=0"] --|上書き|--> cache[(キャッシュ)]
query2["クエリ2<br />offset=10"] --|上書き|--> cache
cache --|最新データのみ<br />(データ損失)|--> result["結果"]
このような課題を解決するために、フィールドポリシーの read 関数と merge 関数が威力を発揮するのです。
解決策
フィールドポリシーの基本構造
フィールドポリシーは、Apollo Client の InMemoryCache 初期化時に Type Policies の一部として設定します。基本的な構造は以下の通りです。
typescriptimport { InMemoryCache } from '@apollo/client';
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
// フィールド名: フィールドポリシー
posts: {
// キャッシュキーを決定する引数
keyArgs: ['filter'],
// データをマージする際の処理
merge(existing, incoming, { args }) {
// マージロジック
},
// データを読み取る際の処理
read(existing, { args }) {
// 読み取りロジック
},
},
},
},
},
});
この設定により、Query 型の posts フィールドに対して、カスタムのキャッシュ動作を定義できます。
merge 関数:データの書き込み制御
merge 関数は、新しいデータをキャッシュに保存する際に呼び出されます。既存データと新規データをどのように結合するかを制御できるのです。
関数シグネチャ
typescripttype FieldMergeFunction<TExisting, TIncoming> = (
existing: TExisting | undefined,
incoming: TIncoming,
options: {
args: Record<string, any> | null;
field: FieldNode;
fieldName: string;
storeFieldName: string;
mergeObjects: <T>(existing: T, incoming: T) => T;
cache: InMemoryCache;
readField: ReadFieldFunction;
}
) => TExisting | TIncoming;
主要なパラメータは以下の通りです。
| # | パラメータ | 説明 |
|---|---|---|
| 1 | existing | キャッシュに既に存在するデータ(初回は undefined) |
| 2 | incoming | 新しく取得したデータ |
| 3 | args | クエリ実行時に渡された引数 |
| 4 | readField | キャッシュから他のフィールドを読み取る関数 |
ページネーション実装例
無限スクロールのように、データを蓄積していく場合の merge 関数実装例です。
typescriptconst cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
posts: {
keyArgs: false, // すべてのクエリで同じキャッシュキーを使用
merge(existing = [], incoming, { args }) {
const merged = existing
? existing.slice(0)
: [];
const offset = args?.offset || 0;
// offset 位置から incoming データを挿入
for (let i = 0; i < incoming.length; i++) {
merged[offset + i] = incoming[i];
}
return merged;
},
},
},
},
},
});
この実装により、offset: 0 で取得したデータと offset: 10 で取得したデータが、配列の適切な位置に保存されます。
以下の図は、merge 関数によるデータ蓄積の仕組みを示しています。
mermaidflowchart TB
query1["クエリ1<br/>offset=0, limit=10"] -->|merge| cache[("キャッシュ<br/>[0-9]")]
query2["クエリ2<br/>offset=10, limit=10"] -->|merge| cache2[("キャッシュ<br/>[0-19]")]
cache -->|既存データ保持| cache2
cache2 -->|完全なデータセット| result["結果"]
read 関数:データの読み取り制御
read 関数は、キャッシュからデータを読み取る際に呼び出されます。キャッシュに保存されているデータを加工したり、計算フィールドを実装したりできるのです。
関数シグネチャ
typescripttype FieldReadFunction<TExisting, TResult> = (
existing: TExisting | undefined,
options: {
args: Record<string, any> | null;
field: FieldNode;
fieldName: string;
storeFieldName: string;
cache: InMemoryCache;
readField: ReadFieldFunction;
canRead: CanReadFunction;
toReference: ToReferenceFunction;
}
) => TResult | undefined;
read 関数が値を返すと、その値がキャッシュの値として使用されます。undefined を返すと、通常のキャッシュ読み取り動作が実行されるでしょう。
ページネーション読み取り例
merge 関数でデータを蓄積した後、read 関数で必要な範囲のみを取り出します。
typescriptconst cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
posts: {
keyArgs: false,
merge(existing = [], incoming, { args }) {
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;
},
read(existing, { args }) {
if (!existing) return undefined;
const offset = args?.offset || 0;
const limit = args?.limit || 10;
// 指定範囲のデータを返却
return existing.slice(offset, offset + limit);
},
},
},
},
},
});
この実装により、クエリの引数に応じて適切なデータ範囲が返却されます。
計算フィールドの実装
サーバーから送られてこないフィールドを、クライアント側で計算して提供できます。
typescriptconst cache = new InMemoryCache({
typePolicies: {
Product: {
fields: {
taxIncludedPrice: {
read(_, { readField }) {
// 他のフィールドから値を読み取り
const price = readField<number>('price');
const taxRate =
readField<number>('taxRate') || 0.1;
// 税込価格を計算
return price
? Math.round(price * (1 + taxRate))
: undefined;
},
},
},
},
},
});
これで、GraphQL クエリに taxIncludedPrice を含めると、自動的に計算された値が返されます。
keyArgs:キャッシュキーの制御
keyArgs は、どの引数をキャッシュキーに含めるかを指定します。これにより、異なる引数での呼び出しを別々にキャッシュできるのです。
typescriptconst cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
users: {
// role 引数をキャッシュキーに含める
keyArgs: ['role'],
merge(existing, incoming) {
// role が異なれば別々に保存される
return incoming;
},
},
},
},
},
});
keyArgs の設定パターンは以下の通りです。
| # | 設定値 | 説明 |
|---|---|---|
| 1 | ['arg1', 'arg2'] | 指定した引数のみをキャッシュキーに含める |
| 2 | false | すべての引数を無視(すべて同じキャッシュキー) |
| 3 | [] | すべての引数をキャッシュキーに含める(デフォルト) |
具体例
実践例 1:無限スクロールの実装
無限スクロール機能を実装する際の、完全な実装例を見てみましょう。
Apollo Client の設定
まず、フィールドポリシーを設定した Apollo Client を初期化します。
typescriptimport { ApolloClient, InMemoryCache, gql } from '@apollo/client';
const client = new ApolloClient({
uri: 'https://api.example.com/graphql',
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
posts: {
// すべてのクエリで同じキャッシュを使用
keyArgs: false,
typescript merge(existing = [], incoming, { args }) {
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 read(existing, { args }) {
// キャッシュが空の場合は undefined を返す
if (!existing) return undefined;
const offset = args?.offset ?? 0;
const limit = args?.limit ?? 10;
const end = offset + limit;
// 必要な範囲のデータが揃っているか確認
const hasAllData = existing.slice(offset, end)
.every(item => item !== undefined);
// データが不足している場合は undefined を返してフェッチさせる
if (!hasAllData) return undefined;
return existing.slice(offset, end);
},
},
},
},
},
}),
});
GraphQL クエリの定義
次に、ページネーション用のクエリを定義します。
typescriptconst GET_POSTS = gql`
query GetPosts($offset: Int!, $limit: Int!) {
posts(offset: $offset, limit: $limit) {
id
title
content
createdAt
author {
id
name
}
}
}
`;
React コンポーネントでの利用
useQuery フックと fetchMore 関数を使って、無限スクロールを実装します。
typescriptimport { useQuery } from '@apollo/client';
import { useEffect, useRef } from 'react';
function InfiniteScrollPosts() {
const LIMIT = 10;
const { data, loading, error, fetchMore } = useQuery(GET_POSTS, {
variables: { offset: 0, limit: LIMIT },
});
typescriptconst loadMore = async () => {
const currentLength = data?.posts?.length || 0;
await fetchMore({
variables: {
offset: currentLength,
limit: LIMIT,
},
});
};
typescript// スクロール監視の実装
const observerRef = useRef<IntersectionObserver>();
const lastItemRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (loading) return;
observerRef.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMore();
}
}
);
if (lastItemRef.current) {
observerRef.current.observe(lastItemRef.current);
}
return () => observerRef.current?.disconnect();
}, [loading, data]);
typescript if (error) return <p>Error: {error.message}</p>;
return (
<div>
{data?.posts?.map((post, index) => (
<div
key={post.id}
ref={index === data.posts.length - 1 ? lastItemRef : null}
>
<h2>{post.title}</h2>
<p>{post.content}</p>
<small>{post.author.name}</small>
</div>
))}
{loading && <p>読み込み中...</p>}
</div>
);
}
この実装により、ユーザーがスクロールするたびに新しいデータが読み込まれ、既存のデータは保持されます。
実践例 2:フィルター付きリストのキャッシュ管理
検索条件やフィルター条件ごとに、別々のキャッシュを保持する実装例です。
フィールドポリシーの設定
typescriptconst cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
products: {
// category と sortBy をキャッシュキーに含める
keyArgs: ['filter', ['category'], 'sortBy'],
typescript merge(existing, incoming, { args }) {
// フィルター条件が異なれば別々に保存される
return incoming;
},
typescript read(existing) {
// 既存データをそのまま返す
return existing;
},
},
},
},
},
});
クエリの定義
typescriptconst GET_PRODUCTS = gql`
query GetProducts(
$filter: ProductFilter
$sortBy: SortOrder
) {
products(filter: $filter, sortBy: $sortBy) {
id
name
price
category
stock
}
}
`;
コンポーネントでの利用
typescriptimport { useQuery } from '@apollo/client';
import { useState } from 'react';
function ProductList() {
const [category, setCategory] = useState('all');
const [sortBy, setSortBy] = useState('price_asc');
typescriptconst { data, loading } = useQuery(GET_PRODUCTS, {
variables: {
filter: {
category: category !== 'all' ? category : undefined,
},
sortBy,
},
});
typescript return (
<div>
<select value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="all">すべて</option>
<option value="electronics">家電</option>
<option value="books">書籍</option>
</select>
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
<option value="price_asc">価格:安い順</option>
<option value="price_desc">価格:高い順</option>
<option value="name_asc">名前:昇順</option>
</select>
{loading ? (
<p>読み込み中...</p>
) : (
<ul>
{data?.products?.map((product) => (
<li key={product.id}>
{product.name} - ¥{product.price.toLocaleString()}
</li>
))}
</ul>
)}
</div>
);
}
フィルター条件を変更すると、それぞれの条件ごとにキャッシュされたデータが使用されるため、再度同じ条件で表示する際はネットワークリクエストが発生しません。
実践例 3:リアルタイム計算フィールド
商品の価格に税率を適用した税込価格を、クライアント側で計算する例です。
型定義とフィールドポリシー
typescripttype Product = {
__typename: 'Product';
id: string;
name: string;
price: number;
taxRate?: number;
// taxIncludedPrice は計算フィールド
};
typescriptconst cache = new InMemoryCache({
typePolicies: {
Product: {
fields: {
taxIncludedPrice: {
read(_, { readField }) {
const price = readField<number>('price');
const taxRate = readField<number>('taxRate') ?? 0.1;
if (price === undefined) return undefined;
// 税込価格を計算(小数点以下切り捨て)
return Math.floor(price * (1 + taxRate));
},
},
typescript displayPrice: {
read(_, { readField }) {
const taxIncludedPrice = readField<number>('taxIncludedPrice');
if (taxIncludedPrice === undefined) return undefined;
// 3桁区切りでフォーマット
return `¥${taxIncludedPrice.toLocaleString('ja-JP')}`;
},
},
},
},
},
});
クエリの定義
typescriptconst GET_PRODUCT = gql`
query GetProduct($id: ID!) {
product(id: $id) {
id
name
price
taxRate
# サーバーには存在しないが、クライアント側で計算される
taxIncludedPrice
displayPrice
}
}
`;
コンポーネントでの表示
typescriptimport { useQuery } from '@apollo/client';
function ProductDetail({ productId }: { productId: string }) {
const { data, loading, error } = useQuery(GET_PRODUCT, {
variables: { id: productId },
});
typescript if (loading) return <p>読み込み中...</p>;
if (error) return <p>Error: {error.message}</p>;
const { product } = data;
return (
<div>
<h1>{product.name}</h1>
<p>本体価格: ¥{product.price.toLocaleString()}</p>
<p>税率: {(product.taxRate * 100).toFixed(1)}%</p>
<p>税込価格: {product.displayPrice}</p>
</div>
);
}
この実装により、サーバーから price と taxRate のみが送られてきても、クライアント側で自動的に taxIncludedPrice と displayPrice が計算されます。
以下の図は、計算フィールドの動作フローを示しています。
mermaidflowchart LR
server["サーバー"] --|price: 1000<br/>taxRate: 0.1|--> cache[(キャッシュ)]
cache --|read 関数実行|--> calc["計算処理"]
calc --|taxIncludedPrice: 1100<br/>displayPrice: ¥1,100|--> component["コンポーネント"]
デバッグとトラブルシューティング
フィールドポリシーの実装時によく遭遇するエラーと解決方法を紹介します。
エラー 1:Cannot read property of undefined
エラーコード: TypeError: Cannot read property 'slice' of undefined
typescript// エラーが発生するコード
merge(existing, incoming, { args }) {
const merged = existing.slice(0); // existing が undefined の場合エラー
// ...
}
発生条件: 初回のデータ取得時に existing が undefined であるため
解決方法: デフォルト値を設定する
typescriptmerge(existing = [], incoming, { args }) {
const merged = existing.slice(0); // 安全にスライス可能
// ...
}
エラー 2:無限ループ
エラーコード: Maximum update depth exceeded
エラーメッセージ:
sqlError: Maximum update depth exceeded. This can happen when a component
repeatedly calls setState inside componentWillUpdate or componentDidUpdate.
発生条件: read 関数内で readField を使って同じフィールドを読み取ろうとした場合
typescript// エラーが発生するコード
read(existing, { readField }) {
// 自分自身を読み取ろうとして無限ループ
const self = readField('posts');
return self;
}
解決方法: existing パラメータを使用する
typescriptread(existing) {
// existing を直接使用
return existing;
}
エラー 3:データが更新されない
発生条件: keyArgs の設定が不適切で、異なる引数でも同じキャッシュが使われてしまう
解決方法: 適切な keyArgs を設定する
typescript// 修正前:すべて同じキャッシュになる
fields: {
users: {
keyArgs: false,
}
}
// 修正後:role ごとに別々のキャッシュ
fields: {
users: {
keyArgs: ['role'],
}
}
まとめ
Apollo Client のフィールドポリシーは、read 関数と merge 関数を活用することで、キャッシュの読み書きを自由にカスタマイズできる強力な機能です。
本記事で解説した内容を振り返ってみましょう。
| # | 項目 | ポイント |
|---|---|---|
| 1 | merge 関数 | 新しいデータをキャッシュに保存する際の処理を制御 |
| 2 | read 関数 | キャッシュからデータを読み取る際の処理を制御 |
| 3 | keyArgs | どの引数をキャッシュキーに含めるかを指定 |
| 4 | ページネーション | データを蓄積しながら適切な範囲を返却 |
| 5 | 計算フィールド | サーバーにないフィールドをクライアント側で生成 |
無限スクロール、フィルター付きリスト、計算フィールドなど、さまざまなユースケースに対応できることがお分かりいただけたでしょう。
フィールドポリシーを適切に設定することで、ネットワークリクエストを最小限に抑え、ユーザー体験を大幅に向上させることができます。最初は複雑に感じるかもしれませんが、基本的なパターンを理解すれば、多くのケースに応用できるはずです。
ぜひ実際のプロジェクトでフィールドポリシーを活用し、より高度なキャッシュ制御を実現してみてください。
関連リンク
articleApollo Client のフィールドポリシー入門:read/merge で高度なキャッシュ制御を実装
articleApollo Client のキャッシュ初期化戦略:既存データ注入・rehydration・GC 設定
articleApollo のキャッシュ思想を俯瞰する:正規化・型ポリシー・部分データの取り扱い
articleApollo GraphOS を用いた安全なリリース運用:Schema Checks/Launch Darkly 的な段階公開
articleApollo で“キャッシュが反映されない”を 5 分で直す:ID/ポリシー/write 系の落とし穴
articleApollo で BFF(Backend for Frontend)最適化:画面別スキーマと Contract Graph の併用
articlemacOS(Apple Silicon)で Docker を高速化:qemu/仮想化設定・Rosetta 併用術
articleCline × クリーンアーキテクチャ:ユースケース駆動と境界の切り出し
articleDevin 用リポジトリ準備チェックリスト:ブランチ戦略・CI 前提・テスト整備
articleClaude Code プロンプト設計チートシート:役割・入力・出力フォーマット定番集
articleConvex と Next.js Server Actions “直書き”比較:保守性・安全性・速度をコードで実測
articleBun でリアルタイムダッシュボード:メトリクス集計と可視化を高速化
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 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来