GraphQL Apollo Clientのキャッシュ更新戦略:refetchQueriesとupdate関数の使い分けガイド
はじめに
Apollo ClientにはrefetchQueriesとupdate関数という2つの主要なキャッシュ更新方法があるが、mutation実行後のキャッシュ更新はどちらを使うべき?パフォーマンスを考えるとupdate関数を使うべきでは?
Mutationを実行後のリスト更新を実装する際、この疑問に直面し判断基準について深ぼってみたので共有します。本記事では、Apollo公式の見解を根拠に、実務での判断基準を体系的に解説します。同じように迷っている方の参考になれば幸いです。
TL;DR(結論)
判断に迷う場合、refetchQueriesから始めることをApollo公式が推奨しています。理由は以下の3点。
- Apollo公式が初心者に推奨している方法
- シンプルで保守しやすく、バグが入りにくい
- パフォーマンス問題は実際に発生してから最適化する方が効率的
ただし、update関数が最初から適切な場合もあります。
- 高頻度操作で即座のフィードバックが必要
- 更新対象がシンプルで依存関係がない
- チームがApollo Clientのキャッシュ構造に精通している
Apollo公式のキャッシュ更新方法
Apollo Clientでは、mutation後のリストのキャッシュ更新について2つの方法を提供しています。
1. refetchQueries(クエリの再取得)
影響を受けるクエリを指定して、サーバーから最新データを再取得する方法です。
const [addTodo] = useMutation(ADD_TODO, {
refetchQueries: [
'GetTodos', // クエリ名で指定
GET_TODOS // DocumentNodeで指定
]
});
メリット:
- シンプルで理解しやすい
- データの整合性が保証される
- 実装が1行で済む
- バックエンドのビジネスロジックに依存しない
デメリット:
- 追加のネットワークリクエストが発生する
- レスポンスタイムがやや長くなる(通常200〜500ms程度)
2. update関数(手動キャッシュ更新)
キャッシュを直接操作して、ネットワークリクエストなしで更新する方法です。
const [addTodo] = useMutation(ADD_TODO, {
update(cache, { data: { addTodo } }) {
const existingTodos = cache.readQuery({ query: GET_TODOS });
cache.writeQuery({
query: GET_TODOS,
data: {
todos: [...existingTodos.todos, addTodo]
}
});
}
});
メリット:
- ネットワークリクエストが不要
- 即座にUIが更新される(1〜10ms程度)
デメリット:
- 実装が複雑になる
- バックエンドのビジネスロジックをフロントエンドで再実装する必要がある
- バグが入りやすい
- 保守コストが高い
Apollo公式の推奨
Apollo公式ドキュメント「Updating local data」では、以下のように明記されています。
Supported methods
The most straightforward way to update your local data is to refetch any queries that might be affected by the mutation. However, this method requires additional network requests.
If your mutation returns all of the objects and fields that it modified, you can update your cache directly without making any followup network requests. However, this method increases in complexity as your mutations become more complex.
If you're just getting started with Apollo Client, we recommend refetching queries to update your cached data. After you get that working, you can improve your app's responsiveness by updating the cache directly.
Apollo公式は、初学者や新規プロジェクトではrefetchQueriesから始めることを推奨しています。動作を確認した後、必要に応じてupdate関数での最適化を検討する流れです。
判断フローチャート
どちらを使うべきか迷った時は、以下のフローチャートが参考になります↓
判断の考え方:
- リストへの追加・削除や複雑な依存関係がない場合は自動更新される
- 多くの場合、
refetchQueriesから始めるのが効率的 - 特定の条件を満たす場合、
update関数も有効な選択肢
「早すぎる最適化」について考える
GraphQLのキャッシュ更新戦略を考える上で、参考になる原則があります。
Premature Optimization(早すぎる最適化)の考え方
Premature optimization is the root of all evil(早すぎる最適化は諸悪の根源)
— Donald Knuth
この原則は、「最適化は実際の問題が明確になってから行う方が効果的」という考え方です。GraphQLのキャッシュ更新においても、この視点は参考になります。
「早すぎる最適化」が課題になる理由
1. 実際の問題を把握する前に最適化すると、間違った部分を最適化してしまう可能性がある
- 推測ではなく、実測に基づいて判断する方が確実
- ほとんどのmutationは低頻度(フォーム送信、ボタンクリックなど)
- 200〜500msの遅延は多くの場合、ユーザー体験に大きな影響を与えない
2. 複雑な実装によるコストとのバランス
- 実装時間: 1分 → 1時間〜
- 保守コスト: ほぼゼロ → バックエンド変更毎に見直し
- バグリスク: 極めて低い → 高い
- テスト: 不要 → 単体テスト必須
3. パフォーマンス問題の原因は様々
- SQLクエリの最適化不足、N+1問題、アルゴリズムの非効率性なども要因になる
- キャッシュ更新の200msよりも、バックエンドの処理時間の方が影響が大きいこともある
効率的な開発フローの一例
// ステップ1: refetchQueries で実装
const [addTodo] = useMutation(ADD_TODO, {
refetchQueries: ['GetTodos']
});
// ステップ2: パフォーマンス計測
// Chrome DevToolsでNetwork timingを確認
// - UXに影響があるか?
// - ユーザーが気になる程度の遅延が発生しているか?
// - 他のボトルネックはないか?
// ↓ 明確な問題が確認された場合
// ステップ3: update関数での最適化を検討
const [addTodo] = useMutation(ADD_TODO, {
optimisticResponse: { /* ... */ },
update(cache, { data }) {
// 必要な更新を行う
}
});
この順序で進めることで:
- 早く動くものをリリースできる
- 実際の問題に基づいて判断できる
- 不要な複雑性を避けられる
- チーム全体の生産性向上につながる
ただし、要件やチームの状況によっては、最初からupdate関数を選択することも合理的です。
実践例で理解する使い分け
実際のコード例を通して、各アプローチの特徴を見ていきましょう。
ケース1: TODOの新規追加
リストに新しい要素を追加する場合の実装例です。
mutation定義
const ADD_TODO = gql`
mutation AddTodo($input: AddTodoInput!) {
addTodo(input: $input) {
id
title
completed
createdAt
__typename
}
}
`;
const GET_TODOS = gql`
query GetTodos {
todos {
id
title
completed
createdAt
}
}
`;
アプローチ比較
パターンA: refetchQueriesを使用
const [addTodo] = useMutation(ADD_TODO, {
refetchQueries: ['GetTodos']
});
// シンプル、確実、保守しやすい
特徴:
- 実装が1行で完結
- サーバーから最新データを取得するため確実
- バックエンドの並び替えロジックなども自動的に反映される
- コードレビューで意図が明確に伝わる
パターンB: update関数を使用
const [addTodo] = useMutation(ADD_TODO, {
update(cache, { data: { addTodo } }) {
// 既存のTODOリストを取得
const existingTodos = cache.readQuery({
query: GET_TODOS
});
if (!existingTodos) return;
// 新しいTODOを追加
cache.writeQuery({
query: GET_TODOS,
data: {
todos: [...existingTodos.todos, addTodo]
}
});
}
});
特徴:
- 即座にUIが更新される(ネットワークリクエストなし)
- 実装は10行以上になる
- バックエンドで並び替えロジックがある場合、フロントエンドでも実装が必要
- エラーハンドリングの実装が必要
- 複数のクエリが影響を受ける場合、全て手動で更新が必要
どちらを選ぶか:
- 一般的なTODO追加の場合、パターンAの方がシンプルで保守しやすい
- 高頻度で連続的に追加される場合や、即座のフィードバックが重要な場合はパターンBも選択肢
ケース2: 複雑な依存関係
1つのmutationが複数のフィールドに影響するケースです。
状況設定
プロジェクト管理アプリで、タスクを完了すると以下が変わる。
- タスクの
completedフィールド - プロジェクトの
completedTasksCount - プロジェクトの
progress(完了率) - プロジェクトの
estimatedCompletion(完了予定日)
mutation定義
const COMPLETE_TASK = gql`
mutation CompleteTask($taskId: ID!) {
completeTask(taskId: $taskId) {
task {
id
completed
__typename
}
project {
id
completedTasksCount
progress
estimatedCompletion
__typename
}
}
}
`;
const GET_PROJECT = gql`
query GetProject($projectId: ID!) {
project(id: $projectId) {
id
name
completedTasksCount
totalTasksCount
progress
estimatedCompletion
tasks {
id
title
completed
}
}
}
`;
アプローチ比較
パターンA: refetchQueriesを使用
const [completeTask] = useMutation(COMPLETE_TASK, {
refetchQueries: ['GetProject']
});
特徴:
- 依存関係が複雑でも1行で対応
- バックエンドの計算ロジックがそのまま反映される
- ビジネスロジックの変更に対して修正が不要
パターンB: update関数を使用
const COMPLETE_TASK = gql`
mutation CompleteTask($taskId: ID!) {
completeTask(taskId: $taskId) {
task {
id
completed
__typename
}
project {
id
completedTasksCount
progress
estimatedCompletion
__typename
}
}
}
`;
const GET_PROJECT = gql`
query GetProject($projectId: ID!) {
project(id: $projectId) {
id
name
completedTasksCount
totalTasksCount
progress
estimatedCompletion
tasks {
id
title
completed
}
}
}
`;
// パターンA: refetchQueriesを使用
const [completeTask] = useMutation(COMPLETE_TASK, {
refetchQueries: ['GetProject']
});
// パターンB: update関数を使用
const [completeTask] = useMutation(COMPLETE_TASK, {
update(cache, { data }) {
// 1. タスクの completed フィールドを更新
cache.modify({
id: cache.identify(data.completeTask.task),
fields: {
completed: () => true
}
});
// 2. プロジェクトの統計情報を更新
cache.modify({
id: cache.identify(data.completeTask.project),
fields: {
// 完了タスク数の更新
completedTasksCount: (prev) => {
// カウント更新ロジック
},
// 進捗率の計算ロジックを再実装
progress: (prev) => {
// バックエンドと同じ計算式をフロントエンドでも実装
},
// 完了予定日の再計算ロジックを再実装
estimatedCompletion: () => {
// 複雑な予測アルゴリズムをフロントエンドでも実装
}
}
});
// 3. プロジェクトのタスクリスト全体を取得して更新
const projectData = cache.readQuery({
query: GET_PROJECT,
variables: { projectId: data.completeTask.project.id }
});
if (projectData) {
// リスト内の該当タスクを探して更新
cache.writeQuery({
query: GET_PROJECT,
variables: { projectId: data.completeTask.project.id },
data: {
project: {
...projectData.project,
tasks: projectData.project.tasks.map(task => {
// タスクの更新ロジック
})
}
}
});
}
// 4. さらに影響を受ける可能性のある他のクエリも同様に更新...
}
});
特徴:
- 即座にUIが更新される
- バックエンドのロジックをフロントエンドでも実装(重複)
- ビジネスロジック変更時に2箇所の修正が必要
- 複雑な実装のためテストが重要
どちらを選ぶか:
- 複雑な依存関係がある場合、パターンAの方が実装・保守コストが低い
- 即座のフィードバックが必要で、チームがキャッシュ構造を理解している場合はパターンBも選択肢
ケース3: 高頻度操作
update関数が効果的なケースです。
状況設定
リアルタイムチャットアプリで、ユーザーが「いいね」ボタンを連打する可能性がある。
要件
- ユーザーは1秒に複数回「いいね」を押す可能性がある
- 即座にUIにフィードバックを返したい
- ネットワーク遅延によるUX低下を避けたい
実装例
const LIKE_MESSAGE = gql`
mutation LikeMessage($messageId: ID!) {
likeMessage(messageId: $messageId) {
id
likesCount
likedByCurrentUser
__typename
}
}
`;
const [likeMessage] = useMutation(LIKE_MESSAGE, {
optimisticResponse: ({ messageId }) => ({
__typename: 'Mutation',
likeMessage: {
__typename: 'Message',
id: messageId,
likesCount: null, // サーバーから返される
likedByCurrentUser: true
}
}),
update(cache, { data: { likeMessage } }) {
cache.modify({
id: cache.identify(likeMessage),
fields: {
likesCount: () => likeMessage.likesCount,
likedByCurrentUser: () => likeMessage.likedByCurrentUser
}
});
}
});
このケースでupdate関数が効果的な理由:
- 高頻度操作で即座のフィードバックがUXに直結する
- 更新対象が単純(単一オブジェクトの2フィールドのみ)
- Optimistic UIと組み合わせることで最高のUXを実現
- 他のデータへの複雑な依存関係がない
実装時の考慮ポイント
実装アプローチを選択する際の考慮ポイントを紹介します。
ポイント1: パフォーマンスの必要性を見極める
実測に基づいた判断
// まずシンプルな実装で動作確認
const [addTodo] = useMutation(ADD_TODO, {
refetchQueries: ['GetTodos']
});
// Chrome DevToolsで実際のパフォーマンスを計測
// - ユーザー体験に影響があるか?
// - 操作頻度はどの程度か?
// - 他にボトルネックはないか?
判断の視点:
- 200〜500ms程度の遅延が実際のユーザー体験に影響を与えているか
- 高頻度操作なのか、低頻度操作なのか
- 即座のフィードバックがビジネス要件として必要か
パフォーマンス以外の要因も考慮
パフォーマンスが課題の場合でも、まずバックエンド側の最適化を検討する選択肢もあります:
- SQLクエリの最適化
- N+1問題の解決
- キャッシュ戦略の見直し
フロントエンドのキャッシュ更新だけでなく、システム全体で最適化ポイントを探ることが効果的な場合があります。
ポイント2: キャッシュの複雑性を理解する
キャッシュの構造と管理
// 単純に見えても、実際には複雑な更新が必要なケース
const [updateProject] = useMutation(UPDATE_PROJECT, {
update(cache, { data: { updateProject } }) {
// プロジェクト詳細を更新したつもりでも...
cache.writeQuery({
query: GET_PROJECT,
variables: { id: updateProject.id },
data: { project: updateProject }
});
// 他にも更新が必要な可能性:
// - プロジェクト一覧のキャッシュ
// - プロジェクト統計のキャッシュ
// - 関連タスクのキャッシュ
// など
}
});
refetchQueriesのシンプルさ
// 複数のビューが関連する場合もシンプルに対応
const [updateProject] = useMutation(UPDATE_PROJECT, {
refetchQueries: [
'GetProject',
'GetProjectList',
'GetProjectStats'
]
});
// すべてのビューが確実に更新される
考慮すべき点:
- 1つのデータが複数のクエリで使用されているか
- 正規化されたキャッシュ構造をチーム全体が理解しているか
- すべての関連キャッシュを手動で更新できるか
パフォーマンスと保守性のバランス
実際のプロジェクトでは、様々な要因を考慮してバランスを取る必要があります。
数値で見る特性の違い
| 項目 | refetchQueries | update関数 |
|---|---|---|
| 実装行数 | 1行 | 数十行〜 |
| 実装時間 | 1分 | 1時間〜 |
| 保守コスト | ほぼゼロ | バックエンド変更毎に見直し |
| バグリスク | 極めて低い | 高い |
| テスト必要性 | 不要(E2Eで十分) | 単体テスト必須 |
| 実行時間 | 200〜500ms | 1〜10ms |
一般的なmutation(フォーム送信、ボタンクリック)
190〜490msの高速化のために、高い実装・保守コストとバグリスクを払うかは、プロジェクトの要件次第です。
高頻度操作(チャット、リアルタイム更新)
この場合は190〜490msがUXに影響する可能性があるため、update関数も有効な選択肢になります。
選択の指針
操作頻度とパフォーマンス要求に応じた選択の考え方:
| 操作の特性 | シンプル性重視 | パフォーマンス重視 |
|---|---|---|
|
低頻度操作 (フォーム送信、設定変更) |
✅ refetchQueries 多くの場合これで十分 |
⚠️ refetchQueries まず試してみる価値あり ⚠️ update関数 必要に応じて検討 |
|
高頻度操作 (チャット、リアルタイム更新) |
✅ update関数 | ✅ Optimistic UI + update関数 UX向上の選択肢 |
判断の目安 (msはあくまで参考値です)
- 500ms以下 → 多くの場合、ユーザーは遅延を気にしない
- 1秒以上 → 最適化を検討(ただし、バックエンド側も調査)
- 高頻度操作 → より厳しい基準(200ms以下を目指すことも)
まとめ
重要なポイント
-
Apollo公式はrefetchQueriesから始めることを推奨
- 初学者や新規プロジェクトに適している
- シンプルで保守しやすい
- バグが入りにくい
-
「早すぎる最適化」の考え方を参考にする
- まず動くものを作る
- 実際に問題が発生してから最適化を検討
- パフォーマンス問題は様々な要因がある
-
update関数が効果的なケースもある
- 高頻度操作で即座のフィードバックが必要
- 更新対象がシンプル
- チームがキャッシュ構造を理解している
-
最終的な判断はプロジェクト次第
- チームのスキルセット
- ビジネス要件
- 長期的な保守性
最後に
GraphQLのキャッシュ更新戦略は、一見すると「パフォーマンスのためにupdate関数を使うべき」と思いがちです。しかし、実務では保守性、開発速度、パフォーマンス、チームの状況など、様々な要因のバランスを取ることが重要です。
Apollo公式が推奨する「まずrefetchQueriesから始める」というアプローチは、多くのプロジェクトで効果的な選択となります。ただし、状況によっては最初からupdate関数を選択することも合理的です。
この記事が、同じように迷っている方の判断の参考になれば幸いです。
参考資料
Discussion