accelint-tanstack-query-best-practices について
accelint-tanstack-query-best-practicesは、Next.js App RouterやServer Componentsを含む、TanStack Queryを使ったReactアプリ開発者向けのスキルです。クエリ設定、キャッシュ設計、ミューテーション、パフォーマンス診断を場面別に案内します。
主な機能
- 01QueryClient設定のガイド
- 02安定したクエリキーのパターン
- 03キャッシュと無効化の戦略
- 04楽観的・悲観的ミューテーションのパターン
- 05オブザーバーのパフォーマンス診断
活用シーン
- 01サーバーとクライアントのTanStack Query設定
- 02クエリキーとキャッシュライフサイクルの設計
- 03操作に応じたミューテーション方式の選択
- 04過剰なクエリオブザーバーの診断
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ gohypergiant/ agent-skills --skill accelint-tanstack-query-best-practicesインストール後、Claude Code で/accelint-tanstack-query-best-practicesを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
assets/output-report-template.mdquery-client.tsreferences/caching-strategy.mdfundamentals.mdmutations-and-updates.mdpatterns-and-pitfalls.mdquery-client-setup.mdquery-keys.mdserver-integration.md詳細
TanStack Query のベストプラクティス
Next.js App Router と Server Components を使用する最新の React アプリケーションにおける、TanStack Query のエキスパート向けパターン。
TanStack Query で絶対にしてはいけないこと
- サーバーでシングルトンの QueryClient を絶対に使用しないでください - ユーザー間でデータ漏えいや競合状態が発生します。あるユーザーのキャッシュデータが別のユーザーに表示されないよう、リクエストごとに独立した QueryClient インスタンスを用意する必要があります。
- クエリデータを絶対に useState に同期しないでください - バックグラウンドでの再取得、無効化、楽観的更新はすべてキャッシュを変更します。ローカル状態のコピーはすぐに古くなり、「保存したのに反映されない」という問題の原因になります。クエリデータを直接使用するか、useMemo で導出してください。
- リスト項目のコンポーネント内にクエリを絶対に配置しないでください - N 個の項目に対して N 個のオブザーバーが作成され、キャッシュ更新のたびに O(n) の反復処理が発生します。200 個のリスト項目で useQuery を呼び出すと、200 件のネットワークリクエストと 200 個のオブザーバーが作成されます。クエリは親コンポーネントにまとめてください。
- 不安定なクエリキーを絶対に使用しないでください - 順序が保証されない配列、Date.now() を使った時間依存のクエリ、または決定論的にシリアライズされないオブジェクトキーは、無限にキャッシュエントリを作成します。キーは安定していて決定論的でなければなりません。
- 依存クエリで enabled ガードを絶対に省略しないでください - パラメーターが undefined のままクエリを実行すると、['tracks', undefined] のような不要なキャッシュエントリが作成され、実際のデータが届く前にネットワークリクエストを浪費します。
- AbortController のシグナルを絶対に無視しないでください - クエリのキャンセルに対応しないと、アンマウントされたコンポーネントの実行中リクエストが残り、帯域幅を浪費するうえ、古いキャッシュエントリを更新するおそれがあります。
- 重要度の高い処理や外部から発生するミューテーションに、絶対に楽観的更新を使用しないでください - 生命に関わる操作、監査証跡システム、外部イベントによって発生するミューテーションでは、UI がサーバーの状態と一致するよう、悲観的更新が必要です。
- 構造的共有は無料だと絶対に考えないでください - 頻繁に更新される 1000 件を超えるデータセットでは、構造的共有に伴う O(n) の深い等価性チェックが CPU 負荷になります。大規模で頻繁に変化するデータでは、structuralSharing: false を指定して無効にしてください。
- 楽観的更新で onSettled を絶対に省略しないでください - onError が例外をスローした場合でも、onSettled はクリーンアップを保証します。これがないと、エラーハンドラーが失敗した際に UI が破損した状態のままになることがあります。リソースのクリーンアップとキャッシュの整合性を保つため、必ず onMutate と onSettled を組み合わせてください。
- キャッシュの無効化は同期的に行われると絶対に考えないでください - invalidateQueries はバックグラウンドで再取得を開始するため、楽観的更新と競合することがあります。ミューテーションが完了する前にバックグラウンドの再取得が楽観的変更を上書きしないよう、onMutate 内で cancelQueries を使用してください。
- 構造的な比較を行わずに setQueryData を絶対に使用しないでください - キャッシュデータを直接設定すると、構造的共有が回避され、参照等価性による最適化が損なわれます。変更されていない部分の参照を維持するため、
setQueryData(key, newValue)ではなく、アップデーター関数でラップしてください:setQueryData(key, (old) => ({ ...old, changed: value }))。 - ハイドレーションの不一致への対処を絶対に忘れないでください - サーバーでレンダリングされたデータがクライアントの想定と異なる場合があります(タイムスタンプ、ユーザー固有のデータ、ランダムなコンテンツなど)。コンテナーに suppressHydrationWarning を使用するか、安定したタイムスタンプと一貫したデータソースを使って、サーバーとクライアントで決定論的にレンダリングされるようにしてください。
TanStack Query を使用する前に確認すること
状態の分類
- これはサーバー状態ですか、それともクライアント状態ですか? TanStack Query はサーバー状態(API データ、データベースレコード、外部システムの状態)を管理します。UI 状態(モーダル、テーマ、フォームの下書き)は Zustand または useState で管理します。
- このデータは初回レンダリング後に変化しますか? 静的な参照データには、TanStack Query の再取得機能が不要な場合があります。より簡単な代替手段で十分かどうか検討してください。
キャッシュ戦略
- このデータにはどの程度の鮮度が必要ですか? 参照テーブルなら staleTime を 1 時間にできます。リアルタイム追跡には、refetchInterval を設定した 5 秒の staleTime が必要です。設定をビジネス要件に合わせてください。
- クエリのライフサイクルはどうなっていますか? 頻繁にアクセスされるデータには、より長い gcTime が必要です。一度だけ表示する詳細ビューでは、積極的にガベージコレクションできます。
オブザーバーのコスト
- このクエリを購読するコンポーネントはいくつありますか? 1 つのキャッシュエントリーに対するオブザーバーが 10 を超える場合は、クエリを親コンポーネントに引き上げることを検討してください。100 を超える場合は、アーキテクチャ上の問題があることを示しています。
- N 個のクエリを作成していますか、それとも N 個のオブザーバーを持つ 1 つのクエリを作成していますか? リスト項目には、個別の
useQueryフックを呼び出させるのではなく、親クエリから props を渡してください。
使用方法
このスキルでは、コンテキストの使用量を最小限に抑えるため、段階的な開示を採用しています。状況に応じてリファレンスを読み込んでください。
シナリオ 1:クエリクライアントのセットアップ
必須 — ファイル全体を読む:サーバー/クライアントのセットアップパターンについて、query-client-setup.md(約 125 行)と server-integration.md(約 151 行)を最後まで読んでください。
初期セットアップでは、ほかのリファレンスを読み込まないでください。
本番環境向けの設定には assets/query-client.ts をコピーしてください。
シナリオ 2:クエリフックの作成
- 必須:キーのファクトリー設定について、
query-keys.md(約 151 行)を読んでください。 - サーバーコンポーネントを使用する場合:
server-integration.mdを読んでください。 - ミューテーションを実装する場合を除き、mutations-and-updates.md は読み込まないでください。
設定値の決定には、以下の判断表を使用してください。
シナリオ 3:ミューテーションの実装
必須 — ファイル全体を読む:mutations-and-updates.md(約 345 行)を最後まで読んでください。ロールバックパターンについては、patterns-and-pitfalls.md を参照してください。
基本的な CRUD ミューテーションでは、caching-strategy.md を読み込まないでください。
シナリオ 4:パフォーマンス問題のデバッグ
- まず、以下のオブザーバー数のしきい値の表を確認してください(121~129 行)。
- オブザーバー数が 50 を超える場合:
patterns-and-pitfalls.mdを読んでください。 - 大規模なデータセットに関する問題の場合:構造共有について、
fundamentals.mdを読んでください。 - すべてのリファレンスを読み込まないでください。まず問題を診断し、その後、対象を絞って内容を読み込んでください。
シナリオ 5:多層キャッシュ戦略
必須:Next.js の use cache、TanStack Query、HTTP キャッシュを組み合わせたパターンについて、caching-strategy.md(約 198 行)を読んでください。
クライアント側の TanStack Query のみを使用する場合は、読み込まないでください。
クエリ設定の判断マトリクス
| データの種類 | staleTime | gcTime | refetchInterval | structuralSharing | 備考 |
|---|---|---|---|---|---|
| 参照/ルックアップ | 1hr | Infinity | - | true | 国、カテゴリ、静的な列挙値 |
| ユーザープロフィール | 5min | 10min | - | true | 変更頻度が低く、鮮度は中程度 |
| リアルタイム追跡 | 5s | 30s | 5s | false | 更新頻度が高く、ペイロードが大きい |
| ライブダッシュボード | 2s | 1min | 2s | サイズによる | 鮮度とパフォーマンスのバランス |
| 詳細ビュー | 30s | 2min | - | true | 必要に応じて取得し、キャッシュは中程度 |
| 検索結果 | 1min | 5min | - | true | キャッシュ可能で、時間的な制約はない |
ミューテーションパターンの選択
| シナリオ | パターン | 使用する場面 |
|---|---|---|
| フォーム送信 | 悲観的 | 複数ステップのフォーム、サーバーでの検証が必要な場合、先に進む前にエラーメッセージが必要な場合 |
| トグル/チェックボックス | 楽観的 | 二値状態の変更、低レイテンシが必要な場合、簡単にロールバックできる場合 |
| ドラッグ&ドロップ | 楽観的 | 即時の視覚的フィードバックが不可欠な場合、並べ替え操作、重要度の低いデータ |
| 一括操作 | 悲観的 | 複数の項目を扱い、一部が失敗する可能性があり、成功した操作をユーザーに確認する必要がある場合 |
| 生命に関わる操作 | 悲観的 | UI をサーバーの実際の状態に一致させる必要がある、医療、金融、安全性が重要なシステム |
| 監査証跡が必要 | 悲観的 | オペレーターの操作と記録されたイベントを正確に一致させる必要があるコンプライアンスシステム |
クエリキーの設計
一貫性のある無効化のために、階層型ファクトリーを使用します。
// Recommended structure
export const keys = {
all: () => ['domain'] as const,
lists: () => [...keys.all(), 'list'] as const,
list: (filters: string) => [...keys.lists(), filters] as const,
details: () => [...keys.all(), 'detail'] as const,
detail: (id: string) => [...keys.details(), id] as const,
};
// Invalidation examples
queryClient.invalidateQueries({ queryKey: keys.all() }); // Invalidate everything
queryClient.invalidateQueries({ queryKey: keys.lists() }); // Invalidate all lists
queryClient.invalidateQueries({ queryKey: keys.detail(id) }); // Invalidate one item
キーの安定性に関するルール:
- 決定的なシリアライズ(結合する前に配列をソートする)
- 時間に依存する値を使用しない(Date.now()、ランダムな ID)
- 型の一貫性を保つ('1' と 1 を混在させない)
- オブジェクトの形状を安定させる(キーをソートするか、シリアライズする)
サーバーとクライアントの統合パターン
| レイヤー | 目的 | 無効化方法 | キャッシュのスコープ |
|---|---|---|---|
| Next.js use cache | データベースの負荷を軽減する | revalidateTag() または updateTag() | リクエスト間、サーバー側 |
| TanStack Query | クライアント側の状態管理 | queryClient.invalidateQueries() | ブラウザーのタブごと |
| ブラウザーの HTTP キャッシュ | ネットワークリクエストをなくす | Cache-Control ヘッダー | ブラウザーごと |
統一された無効化戦略:
- サーバーとクライアントの両方のキャッシュで同じキー・ファクトリーを使用する
- サーバーのミューテーションでは updateTag(keys.detail(id).tag) を呼び出す
- クライアントのミューテーションでは queryClient.invalidateQueries({ queryKey: keys.detail(id) }) を呼び出す
- 両方のキャッシュを同じ階層で同期させる
オブザーバー数のしきい値
| オブザーバー数 | パフォーマンスへの影響 | 必要な対応 |
|---|---|---|
| 1-5 | 無視できる程度 | なし |
| 6-20 | 最小限 | 監視する。すぐに対応する必要はありません |
| 21-50 | 更新時に顕著 | クエリを親コンポーネントに引き上げることを検討する |
| 51-100 | オーバーヘッドが大きい | リファクタリングする:クエリを引き上げるか、select を使用する |
| 100+ | 重大な影響 | 直ちにリファクタリングする:単一のクエリを使い、props を配布する |
診断:
- 開発環境で TanStack Query DevTools を開く
- オブザーバー数の多いキャッシュエントリーを探す
- そのキーを持つ useQuery 呼び出しをコードベース内で検索する
- 親コンポーネントまたは共有キャッシュエントリーを使うようにリファクタリングする
クエリフックのパターン
| パターン | 用途 | 例 |
|---|---|---|
| useSuspenseQuery | Server Components との統合、Suspense 境界 | useSuspenseQuery({ queryKey, queryFn }) |
| enabled を指定した useQuery | 依存クエリ、条件付きフェッチ | useQuery({ queryKey, queryFn, enabled: !!userId }) |
| select を指定した useQuery | データ変換、サブセットの選択 | useQuery({ queryKey, queryFn, select: selectFn }) — selectFn は安定したモジュールレベルの変数に抽出します。インライン関数はレンダーのたびに再実行されます |
| 楽観的な useMutation | 低遅延の UI 更新、簡単に元に戻せる操作 | useMutation({ onMutate, onError, onSettled }) |
| 悲観的な useMutation | 重要度の高い操作、サーバーでの検証 | useMutation({ onSuccess }) |
よくあるエラーパターンと解決策
| 症状 | 根本原因 | 解決策 | 解決しない場合の代替策 |
|---|---|---|---|
| 保存後にデータが更新されない | クエリデータをuseStateにコピーしている | クエリデータを直接使用し、useMemoで導出する | refetch()メソッドで強制的に再取得し、ネットワークタブで実際のAPIレスポンスを確認する |
| リクエストが無限に発生する | クエリキーが不安定(Date.now()、ソートされていない配列) | 決定的なキー構築を使用する | 変更検知を追加する:const requestCount = useRef(0); useEffect(() => { requestCount.current++; if (requestCount.current > 10) console.error('Infinite loop detected', queryKey); }, [data]); キーの安定性パターンについてはfundamentals.mdを参照してください |
| 同じリクエストがN回発生する | リストアイテムごとにクエリを実行している | クエリを親に引き上げ、データをpropsで渡す | すべてのコンポーネントで同一のqueryKey(同じオブジェクト参照または値)を使用していることを確認する:const queryKey = useMemo(() => keys.list(filters), [filters]); 高速なリクエストを重複排除するため、staleTimeを30秒に増やす |
| パラメーターがundefinedの状態でクエリが実行される | enabledガードがない | enabled: Boolean(dependency)を追加する | placeholderDataを使って読み込み状態を表示し、queryFnに型ガードを追加して早期にエラーをスローする |
| リストの描画が遅い | N個のクエリとN個のオブザーバー | 親で単一のクエリを実行し、propsで分配する | selectを使って一部のみを購読し、仮想スクロールを実装してマウントされるコンポーネント数を減らす |
| キャッシュが削除されない | 頻繁に変化するデータにgcTime: Infinityを設定している | gcTimeをデータのライフサイクルに合わせる | queryClient.removeQueries()で強制的に削除し、DevToolsでキャッシュサイズを監視する |
| UIに古いデータが一瞬表示される | サーバーキャッシュが古く、クライアントキャッシュは新しい | 同じキーを使って無効化を統一する | サーバーのpropsからinitialDataを使い、ハイドレーション済みのクエリではrefetchOnMount: falseを設定する |
| 楽観的更新がロールバックされない | onErrorでコンテキストを復元していない | onErrorでonMutateのコンテキストを使う | invalidateQueriesで強制的に無効化し、以前の状態のスナップショットを使って手動でロールバックする |
| サーバーのハイドレーションで不一致が起きる | SSRでタイムスタンプやユーザー固有のデータを使用している | コンテナにsuppressHydrationWarningを使う | dynamic importとssr: falseによるクライアントのみの描画を使うか、タイムスタンプをUTCに正規化する |
| クエリが再取得されない | enabled: falseガードによるブロック、またはgcTimeの期限切れ | enabledの条件を確認し、クエリがpredicateでフィルタリングされていないことを確認する | gcTimeを増やしてキャッシュをより長く保持する。ポーリング動作にはrefetchIntervalを使う。staleTime: Infinityによってバックグラウンド再取得が妨げられていないか確認する |
| サーバーアクションで無効化されない | updateTag/revalidateTagとqueryClientで異なるキーを使っている | サーバーとクライアントの両方のキャッシュで同じキー生成関数を使う | サーバーアクションの後にrouter.refresh()を手動で呼び出し、タグ名がクエリキーの階層と一致することを確認する |
| ミューテーションは成功するがUIが更新されない | onSuccessでの無効化がない、またはqueryKeyが間違っている | onSuccess: () => queryClient.invalidateQueries({ queryKey })を追加する | setQueryDataを使ってキャッシュを手動で更新する:queryClient.setQueryData(keys.detail(id), newData)。queryKeyが完全に一致することを確認する |
トラブルシューティングの決定木
パフォーマンスの問題
ステップ 1:DevTools でオブザーバー数を確認する(136~145 行目のしきい値を使用)
- オブザーバーが 100 を超える → すぐにリファクタリングします。クエリを親コンポーネントに引き上げ、データを props 経由で配布します
- オブザーバーが 51~100 → リファクタリングします。クエリを親コンポーネントに引き上げるか、select を使ってデータの一部を購読します
- オブザーバーが 50 未満 → 問題は別の場所にあります。ステップ 2 に進みます
ステップ 2:データサイズと更新頻度を確認する
- アイテムが 1000 件を超え、頻繁に更新される → 構造共有を無効にします:
structuralSharing: false(詳細は fundamentals.md を参照) - ペイロードが大きい(>500KB) → ネットワークタブを確認し、ページネーションまたは無限クエリを検討します
- 更新が速い(間隔 <1s) → staleTime を短くするか、refetchInterval を使用し、キャッシュ戦略を確認します
ステップ 3:React DevTools Profiler を確認する
- クエリデータを使用するコンポーネントで、不要な再レンダリングが発生していないか確認します
- select 関数がレンダリングごとに再作成されていないか確認します(useCallback を使用します)
- 派生データの変換をインラインで行う代わりに、useMemo を使用すべきか確認します
- コンポーネントのレンダリング時間をプロファイルして、ボトルネックを特定します
ネットワークの問題
不安定な接続:
- リトライロジックを設定します:
retry: 3, retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000) - 本番環境でのリトライ設定については query-client-setup.md を参照してください
トークンの更新が必要な場合:
- queryFn ラッパーに認証インターセプターパターンを実装します
- グローバルな認証ヘッダーには queryClient.setQueryDefaults() を使用します
- トークン更新のパターンについては patterns-and-pitfalls.md を参照してください
競合状態:
- 無効化のタイミングを見直します。setQueryData の前に cancelQueries を使用します
- 楽観的更新がバックグラウンドの再フェッチと競合していないか確認します
- mutation の onMutate で await cancelQueries({ queryKey }) を使用していることを確認します
ハイドレーションの問題
SSR の不一致(コンソールにハイドレーションエラーが表示される):
- コンテナ要素に suppressHydrationWarning を追加します
- データを正規化します。サーバーとクライアントで同一の出力(安定したタイムスタンプ、ソート済みの配列)が生成されることを確認します
- ユーザー固有のデータがサーバー側でレンダリングされていないか確認します
クライアントとサーバーのデータのずれ:
- サーバー上のミューテーションで revalidateTag が実行されるタイミングを確認します
- クライアントのキャッシュが最新である一方、サーバーのキャッシュ(Next.js use cache)が古くなっていないか確認します
- サーバーの props から initialData を使用します:
initialData: serverData, refetchOnMount: false
HydrationBoundary が機能しない:
- クライアントコンポーネントの境界を確認します。HydrationBoundary は 'use client' コンポーネントをラップする必要があります
- dehydratedState がサーバーから正しくシリアライズされているか確認します
- ハイドレーション対象のクエリが shouldDehydrateQuery に含まれていることを確認します
自由度の調整
ミューテーションのリスクに合わせて、ガイダンスの具体性を調整します:
| タスクの種類 | 自由度 | ガイダンスの形式 | 例 |
|---|---|---|---|
| クエリの設定 | 高い自由度 | 一般的なパターンを示す表付きの原則 | 「staleTime をビジネス要件に合わせる」 |
| 楽観的更新 | 中程度の自由度 | ロールバック処理を含む完全なパターン | 「onMutate/onError/onSettled コールバックを使用する」 |
| QueryClient の設定 | 低い自由度 | 重大なセキュリティ警告を含む正確なコード | 「サーバーでシングルトンを絶対に使用しないこと。ファクトリーを使用する」 |
判断基準:「エージェントが間違えた場合、どのような結果になりますか?」
- サーバーでのシングルトンの誤用 → ユーザー間でのデータ漏洩(重大なセキュリティ上の問題)
- オブザーバー数の誤認 → パフォーマンスの低下(中程度の影響)
- staleTime の調整 → 最新性が最適でなくなる(影響は軽微)
重要な注意事項
- クエリキーは決定論的にハッシュ化されます。['tracks', '1'] と ['tracks', 1] は異なるキャッシュエントリを作成します
- ページ再読み込みやハイドレーションをまたいでキャッシュを永続化するには、クエリキーを JSON シリアライズ可能にする必要があります
- pending ステータスを対象とする shouldDehydrateQuery を使用すると、サーバーコンポーネントで await せずにストリーミングできます
- HydrationBoundary はクライアントコンポーネントのみをラップする必要があります。サーバーコンポーネントはこの境界を迂回します
- revalidateTag と updateTag の使い分けは重要です。revalidateTag は stale-while-revalidate を使用し、updateTag は即座に無効化します
- gcTime が期限切れになっていなければ、コンポーネントがマウントされていなくてもバックグラウンドの再フェッチが実行されます
- select を使用する場合、構造共有は 2 回実行されます。1 回目は生データに対して、2 回目は変換後のデータに対して実行されます
selectは正常にキャッシュされたデータに対してのみ実行され、エラー状態では呼び出されません。検証とエラーのスローはqueryFnに記述してください- onMutate での cancelQueries は非常に重要です。バックグラウンドの再フェッチによって楽観的更新が上書きされる可能性があります
- onMutate から返されたコンテキストは、ロールバック状態のために onError と onSettled に渡されます