Apollo Client の正規化設計:`keyFields`/`typePolicies` で ID 設計を固定化
GraphQL を使ったフロントエンド開発では、Apollo Client がデータ管理の中心的な役割を担っています。しかし、キャッシュの正規化が不十分だと、データの重複や不整合が発生してしまうことがあります。
この記事では、Apollo Client の keyFields と typePolicies を活用した ID 設計の固定化について、初心者の方にもわかりやすく解説していきますね。適切な正規化設計を行うことで、パフォーマンスが向上し、データの一貫性も保てるようになるでしょう。
背景
Apollo Client のキャッシュ正規化とは
Apollo Client は、GraphQL クエリで取得したデータを自動的に正規化してキャッシュに保存します。正規化とは、データを一意の識別子(ID)で管理し、重複を排除する仕組みのことです。
たとえば、ユーザー情報を複数のクエリで取得した場合でも、同じユーザーであれば 1 つのキャッシュエントリとして管理されるんですね。これにより、メモリの節約とデータの一貫性が保たれます。
以下の図は、Apollo Client がどのようにデータを正規化してキャッシュに保存するかを示しています。
mermaidflowchart TB
query["GraphQL クエリ"] -->|データ取得| apollo["Apollo Client"]
apollo -->|正規化処理| normalize["正規化エンジン"]
normalize -->|ID 生成| cache[("InMemoryCache")]
cache -->|キャッシュキー| entry1["User:1"]
cache -->|キャッシュキー| entry2["Post:abc"]
cache -->|キャッシュキー| entry3["Comment:xyz"]
この図から、GraphQL クエリで取得したデータが正規化エンジンを経由し、一意の ID でキャッシュに保存される流れが理解できますね。
デフォルトの ID 生成ルール
Apollo Client は、デフォルトで以下のルールに基づいて ID を生成します。
- オブジェクトに
idフィールドがある場合:__typename:idの形式 - オブジェクトに
_idフィールドがある場合:__typename:_idの形式 - どちらもない場合:正規化されずそのまま保存
この自動生成ルールは便利ですが、複雑なデータ構造では不十分な場合があるんです。
課題
ID 設計が不明確な場合の問題点
デフォルトの ID 生成ルールだけに頼ると、以下のような問題が発生する可能性があります。
まず、複合キーを持つオブジェクトの場合、単一の id フィールドだけでは一意性を保証できません。たとえば、ユーザーと投稿の組み合わせで一意になる「いいね」機能などが該当しますね。
次に、カスタムフィールドで ID を管理している場合、Apollo Client がそれを認識できず、データが重複してしまう可能性があります。
さらに、型によって ID の構成が異なる場合、手動で制御しないと正規化が正しく行われません。
以下の図は、ID 設計が不明確な場合に発生する問題を示しています。
mermaidflowchart LR
query1["クエリ A"] -->|取得| user1["User データ"]
query2["クエリ B"] -->|取得| user2["User データ"]
user1 -->|ID 不明| cache1["キャッシュ A<br/>重複エントリ"]
user2 -->|ID 不明| cache2["キャッシュ B<br/>重複エントリ"]
cache1 -.->|本来は同一| cache2
同じユーザーのデータが重複してキャッシュされてしまうことで、メモリの無駄遣いとデータ不整合が発生してしまいますね。
実際に発生する問題例
実際の開発現場では、以下のような問題が起こることがあります。
データの不整合が発生するケースでは、同じユーザー情報を更新しても、一部のコンポーネントだけが更新されない事象が起きます。これは、同じデータが異なるキャッシュキーで保存されているためです。
パフォーマンスの低下も深刻な問題です。不必要にデータが重複すると、メモリ使用量が増加し、React コンポーネントの再レンダリングも増えてしまうでしょう。
デバッグが困難になるという問題もあります。Apollo Client DevTools でキャッシュを確認しても、どのデータがどのキーで保存されているのか把握しづらくなるんですね。
解決策
keyFields による ID 設計の固定化
keyFields を使うと、Apollo Client に対して「この型のオブジェクトは、これらのフィールドを使って ID を生成してください」と明示的に指示できます。
これにより、デフォルトのルールに依存せず、開発者が意図した ID 設計を実現できるんです。
以下のコードは、InMemoryCache の初期化時に typePolicies を設定する基本的な例です。
typescriptimport { InMemoryCache } from '@apollo/client';
// InMemoryCache の初期化
const cache = new InMemoryCache({
typePolicies: {
// User 型の ID 設計を定義
User: {
keyFields: ['id'], // id フィールドを使用
},
},
});
このコードでは、User 型のオブジェクトが id フィールドを使って正規化されるよう設定していますね。
typePolicies の基本構造
typePolicies は、型ごとにキャッシュの動作をカスタマイズするための設定オブジェクトです。主に以下の設定が可能です。
| # | 設定項目 | 説明 |
|---|---|---|
| 1 | keyFields | ID 生成に使用するフィールドを指定 |
| 2 | fields | フィールドごとの読み取り・書き込みロジック |
| 3 | merge | キャッシュ更新時のマージ処理 |
keyFields は最も重要な設定で、これを適切に設定することでデータの正規化が正しく行われるようになります。
複合キーの設定方法
単一のフィールドだけでなく、複数のフィールドを組み合わせて ID を生成することもできます。以下は、userId と postId の組み合わせで一意になる「いいね」機能の例です。
typescriptconst cache = new InMemoryCache({
typePolicies: {
// Like 型は userId と postId の組み合わせで一意
Like: {
keyFields: ['userId', 'postId'],
},
},
});
この設定により、Apollo Client は Like:userId1:postId1 のような複合キーを生成してくれるんですね。
ネストされたフィールドの指定
オブジェクトがネストしている場合も、ドット記法を使って指定できます。
typescriptconst cache = new InMemoryCache({
typePolicies: {
// Product 型は category.id と sku で一意
Product: {
keyFields: ['category', ['id'], 'sku'],
},
},
});
この場合、Product オブジェクトは category.id と sku の組み合わせで ID が生成されますよ。
以下の図は、typePolicies と keyFields がどのように連携してキャッシュキーを生成するかを示しています。
mermaidflowchart TB
data["GraphQL レスポンス"] -->|型情報| policy["typePolicies"]
policy -->|keyFields 参照| fields["指定フィールド抽出"]
fields -->|値取得| generate["キャッシュキー生成"]
generate -->|保存| cache[("InMemoryCache")]
subgraph example["例: Like 型"]
ex1["userId: 'user1'<br/>postId: 'post1'"] -->|結合| ex2["Like:user1:post1"]
end
この仕組みにより、開発者が意図した ID 設計が確実に実現されるんですね。
具体例
基本的な実装例
実際のプロジェクトで Apollo Client を初期化する際の実装例を見ていきましょう。
まず、必要なパッケージをインポートします。
typescriptimport {
ApolloClient,
InMemoryCache,
HttpLink,
} from '@apollo/client';
// GraphQL エンドポイントの設定
const httpLink = new HttpLink({
uri: 'https://api.example.com/graphql',
});
次に、InMemoryCache を設定します。ここで typePolicies を定義していきますよ。
typescript// キャッシュの設定
const cache = new InMemoryCache({
typePolicies: {
// User 型の正規化ルール
User: {
keyFields: ['id'], // id フィールドで一意
},
// Post 型の正規化ルール
Post: {
keyFields: ['slug'], // slug フィールドで一意
},
},
});
この設定により、User は id、Post は slug で正規化されるようになりました。
最後に、Apollo Client のインスタンスを作成します。
typescript// Apollo Client のインスタンス作成
const client = new ApolloClient({
link: httpLink,
cache: cache,
});
export default client;
これで、アプリケーション全体で一貫した ID 設計が適用されるんですね。
複合キーの実装例
SNS アプリケーションで「いいね」機能を実装する場合、ユーザーと投稿の組み合わせで一意になる必要があります。
まず、GraphQL のスキーマ定義を確認しましょう。
graphql# いいね機能の型定義
type Like {
userId: ID!
postId: ID!
createdAt: String!
}
# クエリ定義
type Query {
likes(postId: ID!): [Like!]!
}
次に、Apollo Client 側で Like 型の正規化ルールを設定します。
typescriptconst cache = new InMemoryCache({
typePolicies: {
// Like 型は userId と postId の複合キー
Like: {
keyFields: ['userId', 'postId'],
},
},
});
この設定により、同じユーザーが同じ投稿に対して行った「いいね」は、常に 1 つのキャッシュエントリとして管理されますよ。
実際にクエリを実行してみましょう。
typescriptimport { useQuery, gql } from '@apollo/client';
// いいね一覧を取得するクエリ
const GET_LIKES = gql`
query GetLikes($postId: ID!) {
likes(postId: $postId) {
userId
postId
createdAt
}
}
`;
// コンポーネントでの使用例
function LikesList({ postId }) {
const { data, loading, error } = useQuery(GET_LIKES, {
variables: { postId },
});
if (loading) return <p>読み込み中...</p>;
if (error) return <p>エラーが発生しました</p>;
return (
<ul>
{data.likes.map((like) => (
// キャッシュキーは "Like:userId:postId" の形式で生成される
<li key={`${like.userId}:${like.postId}`}>
ユーザー {like.userId} が {like.createdAt}{' '}
にいいねしました
</li>
))}
</ul>
);
}
このコードでは、Like オブジェクトが userId と postId の組み合わせでキャッシュされるため、データの重複が防げるんですね。
カスタムフィールドを使った実装例
既存のバックエンドで id や _id 以外のフィールドを ID として使っている場合もあります。たとえば、商品マスタで productCode を主キーとしている場合です。
まず、GraphQL のスキーマを確認します。
graphql# 商品型の定義
type Product {
productCode: String!
name: String!
price: Int!
stock: Int!
}
Apollo Client 側で productCode を ID として使うよう設定しますよ。
typescriptconst cache = new InMemoryCache({
typePolicies: {
// Product 型は productCode で一意
Product: {
keyFields: ['productCode'],
},
},
});
これで、Product:ABC123 のようなキャッシュキーが生成されるようになりました。
実際にクエリを使って商品情報を取得してみましょう。
typescriptimport { useQuery, gql } from '@apollo/client';
// 商品情報取得クエリ
const GET_PRODUCT = gql`
query GetProduct($productCode: String!) {
product(productCode: $productCode) {
productCode
name
price
stock
}
}
`;
// コンポーネントでの使用
function ProductDetail({ productCode }) {
const { data } = useQuery(GET_PRODUCT, {
variables: { productCode },
});
// キャッシュキー: "Product:ABC123" の形式で管理される
return data?.product ? (
<div>
<h2>{data.product.name}</h2>
<p>価格: ¥{data.product.price.toLocaleString()}</p>
<p>在庫: {data.product.stock} 個</p>
</div>
) : null;
}
カスタムフィールドを ID として使うことで、既存のデータベース設計に合わせた柔軟な実装が可能になるんですね。
ネストされたオブジェクトの実装例
EC サイトでは、商品がカテゴリーに属しており、カテゴリー ID と商品コードの組み合わせで一意になることがあります。
GraphQL スキーマは以下のようになっているとしましょう。
graphql# カテゴリー型
type Category {
id: ID!
name: String!
}
# 商品型(カテゴリー情報を含む)
type Product {
category: Category!
sku: String!
name: String!
price: Int!
}
Apollo Client でネストされたフィールドを指定します。
typescriptconst cache = new InMemoryCache({
typePolicies: {
// Product 型は category.id と sku の組み合わせで一意
Product: {
keyFields: ['category', ['id'], 'sku'],
},
// Category 型は id で一意
Category: {
keyFields: ['id'],
},
},
});
この設定により、Product は category.id と sku を組み合わせた ID で正規化されますよ。
クエリを実行して動作を確認しましょう。
typescriptimport { useQuery, gql } from '@apollo/client';
// 商品一覧取得クエリ
const GET_PRODUCTS = gql`
query GetProducts {
products {
category {
id
name
}
sku
name
price
}
}
`;
// コンポーネント例
function ProductList() {
const { data } = useQuery(GET_PRODUCTS);
return (
<div>
{data?.products.map((product) => (
// キャッシュキーは "Product:categoryId:sku" の形式
<div key={`${product.category.id}:${product.sku}`}>
<span>{product.category.name}</span>
<h3>{product.name}</h3>
<p>SKU: {product.sku}</p>
<p>¥{product.price.toLocaleString()}</p>
</div>
))}
</div>
);
}
ネストされたフィールドを ID に含めることで、複雑なデータ構造でも正確に正規化できるんですね。
keyFields: false による正規化の無効化
特定の型については、正規化せずにそのまま保存したい場合もあります。たとえば、設定情報や一時的なデータなどです。
typescriptconst cache = new InMemoryCache({
typePolicies: {
// Settings 型は正規化しない
Settings: {
keyFields: false,
},
},
});
keyFields: false を指定すると、その型のオブジェクトは親オブジェクトの一部として直接保存されますよ。
以下の図は、正規化あり・なしでのキャッシュ構造の違いを示しています。
mermaidflowchart LR
subgraph normalized["正規化あり"]
user1["User:1"] -->|参照| posts1["Post:abc"]
end
subgraph notNormalized["正規化なし (keyFields: false)"]
user2["User:2"] -->|埋め込み| settings["settings: {...}"]
end
正規化を無効化することで、シンプルなデータ構造では管理がしやすくなる場合があるんです。
動的な keyFields の実装
関数を使って、動的に ID を生成することも可能です。複雑な条件に基づいて ID を決定したい場合に便利ですよ。
typescriptconst cache = new InMemoryCache({
typePolicies: {
// 動的に ID を生成する例
Article: {
keyFields: (object, context) => {
// draft が true の場合は draftId を使用
if (object.draft) {
return ['draftId'];
}
// それ以外は id を使用
return ['id'];
},
},
},
});
この設定では、記事の下書き状態に応じて異なるフィールドを ID として使用していますね。
関数の引数を活用して、より柔軟な制御も可能です。
typescriptconst cache = new InMemoryCache({
typePolicies: {
Comment: {
keyFields: (
object,
{ typename, selectionSet, fragmentMap }
) => {
// オブジェクトの内容に基づいて条件分岐
if (object.tempId) {
// 一時的な ID を使用
return ['tempId'];
}
if (object.id && object.version) {
// バージョン管理が必要な場合は複合キー
return ['id', 'version'];
}
// デフォルトは id のみ
return ['id'];
},
},
},
});
動的な keyFields を使うことで、アプリケーションの状態や要件に応じた柔軟な ID 設計が実現できるんですね。
まとめ
Apollo Client の keyFields と typePolicies を活用することで、GraphQL アプリケーションのキャッシュ正規化を完全に制御できるようになります。
これまで見てきたように、適切な ID 設計を行うことで以下のメリットが得られますよ。
データの一貫性が保たれることで、同じデータが複数のキャッシュエントリとして重複することがなくなります。UI の更新も確実に反映されるんですね。
パフォーマンスが向上するという点も重要です。不要なデータの重複が減り、メモリ使用量が最適化されます。React コンポーネントの再レンダリングも最小限に抑えられるでしょう。
デバッグが容易になるのも大きな利点です。Apollo Client DevTools でキャッシュの状態を確認する際、どのデータがどのキーで保存されているか明確にわかるため、問題の特定がスムーズになりますね。
開発チームでの認識統一も図れます。typePolicies を明示的に定義することで、ID 設計がコードとして文書化され、チームメンバー全員が同じ理解を持てるんです。
実装時のポイントとしては、以下を意識すると良いでしょう。
まず、プロジェクト初期段階で全ての型について keyFields を定義しておくことをお勧めします。後から追加すると、既存のキャッシュとの整合性に問題が生じる可能性があるためです。
次に、バックエンドの ID 設計と一致させることが重要ですよ。GraphQL スキーマとデータベースの主キーが一致していれば、より自然な実装になります。
テストも忘れずに実施しましょう。Apollo Client の cache.extract() メソッドを使って、期待通りのキャッシュキーが生成されているか確認できますね。
Apollo Client の正規化設計は、アプリケーションの品質を左右する重要な要素です。keyFields と typePolicies を適切に設定して、堅牢で保守性の高いフロントエンドを実現してくださいね。
関連リンク
articleApollo Client の正規化設計:`keyFields`/`typePolicies` で ID 設計を固定化
articleApollo Client のフィールドポリシー入門:read/merge で高度なキャッシュ制御を実装
articleApollo Client のキャッシュ初期化戦略:既存データ注入・rehydration・GC 設定
articleApollo のキャッシュ思想を俯瞰する:正規化・型ポリシー・部分データの取り扱い
articleApollo GraphOS を用いた安全なリリース運用:Schema Checks/Launch Darkly 的な段階公開
articleApollo で“キャッシュが反映されない”を 5 分で直す:ID/ポリシー/write 系の落とし穴
articleMongoDB が遅い原因を一発特定:`explain()`・プロファイラ・統計の使い方
articleApollo Client の正規化設計:`keyFields`/`typePolicies` で ID 設計を固定化
articleCursor コスト最適化:トークン節約・キャッシュ・差分駆動で費用を半減
articleZod の再帰型・木構造設計:`z.lazy` でツリー/グラフを安全に表現
articleCline ガバナンス運用:ポリシー・承認フロー・監査証跡の整備
articleYarn の歴史と進化:Classic(v1) から Berry(v2/v4) まで一気に把握
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 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来