導入: 「データフェッチ」から「状態管理」へ
あなたはすでに useQuery でデータを取得し、staleTime と gcTime (v5 での名称) の違いを理解し、useMutation でデータを更新し、invalidateQueries でキャッシュを無効化していることでしょう。
しかし、アプリケーションが複雑化するにつれ、次のような壁に直面していませんか?
- 「invalidateQueries を実行したら、意図しないクエリまで再フェッチされてしまった」
- 「オプティミスティック・アップデート(楽観的更新)を実装したが、エラー時のロールバック処理が複雑で、データの不整合が怖い」
- 「ドラッグ&ドロップでの並び替えなど、API を介さない UI 操作をキャッシュに即時反映させたいが、useMutation を使うのは大袈裟だ」
この記事は、TanStack Query を単なる「データフェッチライブラリ」から、React アプリケーション全体の状態を司る「高度な状態管理ストア」へと昇華させるための、3 つの実践的なテクニックに焦点を当てます。
基本的な API の解説は一切省略し、パフォーマンスとデータ整合性を極限まで高めるための「上級者向けの」戦略のみを探求します。
1. 外科手術的なキャッシュ制御: invalidateQueries の高度なフィルタリング
queryClient.invalidateQueries({ queryKey: ['todos'] }) は便利ですが、['todos', 'list'], ['todos', 1], ['todos', 'search', 'keyword'] など、['todos'] で始まるすべてのクエリを無効化してしまいます。これは、アクティブなクエリ(現在マウントされているコンポーネントが使用中のクエリ)の意図しない一斉再フェッチを引き起こし、深刻なパフォーマンスボトルネックになり得ます。
より精密な制御、いわば「外科手術的な」キャッシュ無効化には、predicate と refetchType の理解が不可欠です。
H3: predicate によるピンポイント無効化
predicate オプションは、クエリキーだけでなく、クエリインスタンスの内部状態(meta データ、state.dataUpdatedAt など)に基づいて無効化対象をフィルタリングできる強力な関数です。
シナリオ: 特定のメタデータ(例:{ cacheType: 'sensitive' })を持つクエリだけを無効化したい場合。
まず、useQuery 側で meta を定義します。
// types.ts
export interface Todo {
id: number;
title: string;
completed: boolean;
}
// useSensitiveTodos.ts
import { useQuery } from '@tanstack/react-query';
import { Todo } from './types';
import { fetchSensitiveTodos } from './api';
export const useSensitiveTodos \= () \=\> {
return useQuery\<Todo\[\], Error\>({
queryKey: \['todos', 'sensitive'\],
queryFn: fetchSensitiveTodos,
// このクエリにメタデータを付与
meta: {
cacheType: 'sensitive',
},
});
};
次に、invalidateQueries で predicate を使用します。
import { useQueryClient, Query } from '@tanstack/react-query';
import { Todo } from './types';
// ... コンポーネント内
const queryClient \= useQueryClient();
const handleLogout \= () \=\> {
// すべてのクエリを走査し、predicateに一致するものだけを無効化
queryClient.invalidateQueries({
predicate: (query: Query) \=\> {
// query.meta は useQuery で定義した meta オブジェクト
return query.meta?.cacheType \=== 'sensitive';
},
});
// ... ログアウト処理
};
なぜこれが優れているのか?
queryKey だけに依存した無効化は、クエリキーの命名規則に強く依存します。predicate を使えば、クエリキーの構造に関わらず、アプリケーションのドメインロジック(例:「機密データ」「ユーザー設定関連」)に基づいた柔軟なキャッシュ制御が可能になります。
H3: refetchType の戦略的活用
invalidateQueries はデフォルト(refetchType: 'active')では、現在アクティブな(マウントされている)クエリのみを再フェッチします。非アクティブなクエリ(アンマウントされている)は、stale とマークされるだけで、次にマウントされるまで再フェッチされません。
シナリオ: ユーザーが「設定」ページを更新した後、即座に「ダッシュボード」ページ(現在は非アクティブ)の関連データもバックグラウンドで更新しておきたい場合。
import { useQueryClient } from '@tanstack/react-query';
import { useUpdateSettings } from './mutations';
// ... 設定ページコンポーネント内
const queryClient \= useQueryClient();
const updateSettings \= useUpdateSettings({
onSuccess: () \=\> {
// 関連するダッシュボードのデータを無効化
queryClient.invalidateQueries({
queryKey: \['dashboardData'\],
// 'active' (デフォルト): 現在表示中のダッシュボードのみ再フェッチ
// 'inactive': 現在非表示のダッシュボードのみ再フェッチ
// 'all': 表示中・非表示中にかかわらず、すべて再フェッチ
// 'none': staleにはするが、再フェッチはトリガーしない
refetchType: 'all',
});
},
});
なぜこれが優れているのか?
refetchType: 'all' を戦略的に使うことで、ユーザーが次にそのページに遷移した際の待ち時間(ローディングスピナー)を排除できます。
逆に、重要度が低いデータや、バックグラウンドでの API コールを避けたい場合は、refetchType: 'active'(デフォルト)または refetchType: 'none' を明示的に指定することで、ネットワークリクエストを最小限に抑えることができます。
2. 完璧な UI/UX を実現する: オプティミスティック・アップデートの安全な実装
オプティミスティック・アップデートは、API の応答を待たずに UI を即時更新し、UX を劇的に向上させます。しかし、その実装は「エラー時のロールバック」という複雑な問題を伴います。
onMutate で返される context は、このロールバック処理を安全かつ確実に行うための鍵となります。
H3: onMutate と context を用いたロールバック設計
シナリオ: Todo リストのチェックボックスをトグルする際、即座に UI を変更し、万が一 API リクエストが失敗した場合は、以前の状態(チェックボックスの状態とキャッシュ全体)に正確に戻す。
// types.ts
export interface Todo {
id: number;
title: string;
completed: boolean;
}
export type UpdateTodoInput \= Pick\<Todo, 'id'\> & Partial\<Omit\<Todo, 'id'\>\>;
// useToggleTodo.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Todo, UpdateTodoInput } from './types';
import { updateTodo } from './api';
// ロールバック用のコンテキスト型を定義
interface ToggleTodoContext {
previousTodos?: Todo\[\];
}
export const useToggleTodo \= () \=\> {
const queryClient \= useQueryClient();
const queryKey \= \['todos', 'list'\];
return useMutation\<Todo, Error, UpdateTodoInput, ToggleTodoContext\>({
mutationFn: updateTodo, // (variables: UpdateTodoInput) \=\> Promise\<Todo\>
// 1\. Mutation実行直前に発火
onMutate: async (variables) \=\> {
// 1-1. このMutationが完了するまで、関連するクエリの再フェッチをキャンセル
// (オプティミスティック更新がサーバーデータで上書きされるのを防ぐ)
await queryClient.cancelQueries({ queryKey });
// 1-2. ロールバック用に、現在のキャッシュデータを取得
const previousTodos \= queryClient.getQueryData\<Todo\[\]\>(queryKey);
// 1-3. キャッシュをオプティミスティックに更新
queryClient.setQueryData\<Todo\[\]\>(queryKey, (old) \=\>
(old ?? \[\]).map((todo) \=\>
todo.id \=== variables.id
? { ...todo, ...variables }
: todo,
),
);
// 1-4. ロールバック用のデータをcontextとして返す
return { previousTodos };
},
// 2\. Mutationが失敗した場合 (ロールバック処理)
onError: (err, variables, context) \=\> {
// onMutateから返されたcontext (previousTodos) を使ってキャッシュを元に戻す
if (context?.previousTodos) {
queryClient.setQueryData(queryKey, context.previousTodos);
}
// ... エラーUIの表示など
console.error('Failed to update todo:', err);
},
// 3\. Mutationが成功または失敗した場合 (最終処理)
onSettled: (data, error, variables) \=\> {
// オプティミスティック更新が成功したか否かにかかわらず、
// サーバーの最新データとキャッシュを同期させるため、
// 該当クエリを無効化して再フェッチをトリガーする。
queryClient.invalidateQueries({ queryKey });
},
});
};
なぜこれが優れているのか?
- cancelQueries の重要性: onMutate の冒頭で cancelQueries を実行しないと、オプティミスティック更新直後に別の理由(例:コンポーネントのマウント)で再フェッチが走り、UI が「更新 → 古いデータに戻る →API 応答で再度更新」というチラつき(Flicker)を起こす可能性があります。
- context の役割: onError スコープ内で getQueryData を呼び出すと、すでに失敗したオプティミスティック更新後のデータを取得してしまいます。onMutate の実行開始時点のデータを context 経由で渡すことで、確実に「更新前の状態」へのロールバックを保証できます。
- onSettled での invalidateQueries: 成功時はもちろん、失敗時(ロールバック後)も invalidateQueries を呼ぶことで、UI の状態を必ずサーバーの「正」の状態に同期させることができます。これにより、オプティミスティック更新の失敗が UI に残り続ける事態を防ぎます。
3. API コールを不要にする: queryClient.setQueryData による能動的キャッシュ更新
TanStack Query の真価は、useMutation を介さないキャッシュの直接操作、すなわち queryClient.setQueryData にあります。これは、サーバーの状態を変更しない純粋なクライアントサイドの状態変更(例:リストの並び替え、モーダルでの一時的な編集)を扱う際に絶大な威力を発揮します。
H3: UI イベントに応じた即時キャッシュ書き換え
シナリオ: ドラッグ&ドロップ(D&D)ライブラリ(dnd-kit など)を使い、Todo リストの並び順を UI 上で変更した。この並び順はクライアント側でのみ保持し、API には(即座には)送信しない。
import { useQueryClient } from '@tanstack/react-query';
import { Todo } from './types';
// ... D\&Dライブラリのインポート
// import { DragEndEvent } from '@dnd-kit/core';
// ... コンポーネント内
const queryClient \= useQueryClient();
const queryKey \= \['todos', 'list'\];
// D\&Dのドラッグ終了イベントハンドラ
const handleDragEnd \= (event: /\* DragEndEvent \*/ any) \=\> {
const { active, over } \= event;
if (active.id \!== over.id) {
// setQueryDataの第二引数に「updater関数」を渡す
queryClient.setQueryData\<Todo\[\]\>(queryKey, (oldData) \=\> {
if (\!oldData) return \[\];
const oldIndex \= oldData.findIndex((todo) \=\> todo.id \=== active.id);
const newIndex \= oldData.findIndex((todo) \=\> todo.id \=== over.id);
// イミュータブルな配列操作(例:arrayMove関数などを使用)
// ここでは簡易的に実装
const newArray \= \[...oldData\];
const \[movedItem\] \= newArray.splice(oldIndex, 1);
newArray.splice(newIndex, 0, movedItem);
// 新しい配列を返すことでキャッシュが更新される
return newArray;
});
}
};
なぜこれが優れているのか?
- useMutation との使い分け: この操作はサーバーの状態を「更新」するものではなく、クライアント側の「表示状態」を変更するものです。useMutation を使うのは意味論的にも不適切であり、不要な mutationKey の管理や isLoading 状態の発生を招きます。setQueryData は、このようなクライアントサイドの状態変更に最適です。
- Updater 関数の利用: setQueryData(key, newData) のように直接新しいデータを渡すのではなく、setQueryData(key, (oldData) => newData) という「updater 関数」を渡すことが強く推奨されます。
- 安全なイミュータブル更新: Updater 関数は、現在のキャッシュデータ(oldData)を引数に取ります。React の状態更新と同様に、必ずイミュータブル(非破壊的)に oldData を操作し、新しい配列(またはオブジェクト)を返す必要があります。これにより、TanStack Query はデータの変更を正しく検知し、関連するコンポーネントの再レンダリングをトリガーできます。また、競合状態(Race Condition)のリスクを低減します。
結論: あなたのキャッシュは「状態ストア」である
今回探求した 3 つのテクニックは、TanStack Query v5 が単なるデータフェッチのラッパーではないことを示しています。
- predicate と refetchType は、ネットワークリクエストを精密に制御し、パフォーマンスを最適化するメスとなります。
- onMutate の context は、UX を最大化するオプティミスティック・アップデートの信頼性とデータ整合性を担保する安全網です。
- setQueryData の Updater 関数 は、API を介さない UI の状態変更をキャッシュに直接反映させ、TanStack Query を Redux や Zustand のようなクライアント状態管理ストアとしても機能させる鍵となります。
これらのテクニックを習得することで、あなたはキャッシュを「取得したデータ」として受動的に扱うだけでなく、「アプリケーションの現在の状態」として能動的に管理・操作できるようになります。
さらなる探求として、公式ドキュメントの Optimistic Updates や Query Client のセクションを、これらの概念を念頭に置いて再読することをお勧めします。これまで見落としていた深い設計思想に気づくはずです。