「いいね」を押した直後にハートが赤くなり、コンマ数秒後に元の灰色へ戻る。サーバーは 200 を返しているのに、画面だけが嘘をついている。個人開発のアプリでこの挙動を初めて見たとき、私はミューテーションの実装を何度も読み返しました。コードは教科書どおりに見えるのに、表示だけが時々巻き戻る。原因は onError でも mutationFn でもなく、ミューテーションの直前に走っていた別のフェッチでした。
TanStack Query v5(旧 React Query)の不具合の多くは、こういう「個別のバグに見えて、実はキャッシュ整合性の設計問題」という形をとります。ライブラリ自体はよくできていて、単体の API は素直です。けれども、複数のクエリとミューテーションが同じキャッシュを共有し始めた瞬間に、整合性をどう保つかという判断が一気に必要になります。Antigravity の AI エージェントはフックの雛形を数秒で書いてくれますが、この「整合性をどこで担保するか」だけは、生成されたコードを読む側が握っていないと崩れます。
ここでは、私が個人開発の中で実際に踏んだ不整合を、起きる場所ごとに分類して潰していきます。コードの断片を並べるより、「なぜそこで壊れるのか」を先に共有したほうが、明日の自分のコードに効くはずです。
キャッシュが壊れるのは、だいたい3つの地点だけ
数年運用してみて気づいたのは、TanStack Query の不整合はほぼ3つの地点に集約されるということです。第一に、ミューテーション後の無効化境界 が広すぎる/狭すぎる地点。第二に、楽観的更新のロールバック経路 が欠けている地点。第三に、SSR で QueryClient を共有 してしまい、別リクエストのデータが混ざる地点。
逆に言えば、新しいバグに遭遇しても「これは3つのうちどれだ」と問えば、調査範囲がぐっと狭まります。冒頭のハートが巻き戻る例は、二番目に見えて実は「無効化と楽観更新の競合」、つまり一番目と二番目の境界線上の問題でした。順番に見ていきます。
まず v5 の前提 — isPending と signal を雛形に焼き込む
整合性の話に入る前に、v5 で土台になる2点だけ押さえます。v4 までと違い、status: 'loading' は pending に改名され、isPending(キャッシュが空でまだ取得中)と isFetching(再取得中だが古いデータは見せられる)が明確に分かれました。この区別を曖昧にすると、再取得のたびにスケルトンが出るちらつきUIになります。
import { useQuery } from "@tanstack/react-query" ;
export function useUserProfile ( userId : string ) {
return useQuery ({
queryKey: [ "users" , userId, "profile" ],
// signal を必ず受けて fetch に渡す — アンマウント時の中断に直結する
queryFn : ({ signal }) => fetchUserProfile (userId, { signal }),
staleTime: 60_000 ,
gcTime: 5 * 60_000 ,
enabled: Boolean (userId),
});
}
async function fetchUserProfile ( userId : string , { signal } : { signal : AbortSignal }) {
const res = await fetch ( `/api/users/${ userId }` , { signal });
if ( ! res.ok) throw new Error ( `Failed to fetch user: ${ res . status }` );
return ( await res. json ()) as UserProfile ;
}
signal を queryFn の引数で受けて fetch に渡す。これは v4 でも可能でしたが、v5 では「省略する理由がない」標準作法になりました。Antigravity に「ユーザー取得フックを書いて」と頼むと signal を落とした雛形が返ることがあります。私は生成コードをマージする前のチェック項目に必ずこれを入れています。アンマウント時にリクエストを止められるかどうかは、画面遷移の多いアプリほどサーバー負荷に効いてきます。
地点1: 無効化境界 — クエリキーで「どこまで響くか」を表現する
無効化が広すぎると、関係のない画面まで再フェッチが走ります。狭すぎると、更新したのに表示が変わりません。この境界を読みやすくする唯一の方法は、クエリキーを階層化し、文字列リテラルを散らかさずファクトリ経由に統一することです。
// queryKeys.ts — リソース → スコープ → 識別子 → サブリソースの4層
export const queryKeys = {
users: {
all: [ "users" ] as const ,
lists : () => [ ... queryKeys.users.all, "list" ] as const ,
list : ( filters : UserFilters ) => [ ... queryKeys.users. lists (), filters] as const ,
detail : ( id : string ) => [ ... queryKeys.users.all, "detail" , id] as const ,
profile : ( id : string ) => [ ... queryKeys.users. detail (id), "profile" ] as const ,
posts : ( id : string ) => [ ... queryKeys.users. detail (id), "posts" ] as const ,
},
} as const ;
このファクトリがあると、無効化の意図がコードの形で残ります。プロフィール更新後は queryClient.invalidateQueries({ queryKey: queryKeys.users.detail(id) }) と書けば、その下にぶら下がる profile も posts もまとめて無効化されます。一覧の検索条件だけ変えたいなら queryKeys.users.lists() 配下に限定する。境界が「キーの深さ」として目に見えるのが利点です。
実務で効くのは、この規約が AI 生成にも伝播することです。プロジェクト内で queryKeys.users.detail(id) というパターンを2〜3回使うと、Antigravity のエージェントは既存コードを参照して次から自動でこの形に揃えてきます。逆に言えば、最初の数フックを手で丁寧に書く価値がここにあります。as const を全段に効かせておくのも忘れないでください。これを抜くとコールバックで受けるキーの型が緩み、リファクタ時に静かに壊れます。
無効化を removeQueries で代用しないことも一つの判断です。removeQueries はキャッシュごと消すため、画面に残っている古いデータまで一瞬で空になり、スケルトンが再表示されます。invalidateQueries なら古いデータを見せたまま裏で再取得するので、ユーザーには継ぎ目が見えません。「消す」と「無効にする」は別物だと意識すると、境界設計の解像度が上がります。
地点2: 楽観的更新 — 5手をテンプレ化してロールバックを構造で守る
冒頭のハートが巻き戻る問題は、ここでした。楽観的更新は、レスポンスを待たずに「成功した想定」でキャッシュを書き換える手法です。体感速度は体感で2倍ほどに感じられるほど上がりますが、ロールバック経路を一つでも落とすと「成功していないのに表示が変わる」「失敗しても戻らない」が起きます。
私は、楽観的更新を必ず次の5手で書くと決めています。cancelQueries で進行中のフェッチを止める、スナップショットを取る、キャッシュを先行更新する、失敗時に戻す、最後に成否によらず同期する。順番に意味があります。この5手をテンプレ化して固定することを、私は強く推奨します。
import { useMutation, useQueryClient } from "@tanstack/react-query" ;
import { queryKeys } from "@/lib/queryKeys" ;
type Ctx = { previous : PostDetail | undefined };
export function useToggleLike ( postId : string ) {
const qc = useQueryClient ();
const key = queryKeys.posts. detail (postId);
return useMutation < PostDetail , Error , void , Ctx >({
mutationFn : async () => {
const res = await fetch ( `/api/posts/${ postId }/like` , { method: "POST" });
if ( ! res.ok) throw new Error ( `Like failed: ${ res . status }` );
return ( await res. json ()) as PostDetail ;
},
onMutate : async () => {
await qc. cancelQueries ({ queryKey: key }); // (1) 競合フェッチを止める
const previous = qc. getQueryData < PostDetail >(key); // (2) スナップショット
if (previous) {
qc. setQueryData < PostDetail >(key, { // (3) 先行更新
... previous,
liked: ! previous.liked,
likeCount: previous.likeCount + (previous.liked ? - 1 : 1 ),
});
}
return { previous };
},
onError : ( _e , _v , ctx ) => {
if (ctx?.previous) qc. setQueryData (key, ctx.previous); // (4) 復元
},
onSettled : () => {
qc. invalidateQueries ({ queryKey: key }); // (5) サーバーと最終同期
},
});
}
巻き戻りの犯人は (1) の欠落でした。cancelQueries を書かないと、ミューテーション直前に走り出していた useQuery のフェッチが、少し遅れて「古いサーバーレスポンス」でキャッシュを上書きします。先行更新したばかりの値が、その古い値に塗り替えられて巻き戻る。サーバーは正しく 200 を返しているのに表示だけ嘘をつくのは、この競合が理由です。私はこれに2回ハマってから、楽観的更新のスニペットを (1) から始まる固定テンプレートにしました。
(5) を「成功時の setQueryData で代替できないか」と考える人は多いです。けれど、サーバーが正規化した値(採番された id、関連カウントの最終確定値など)を手元に取り込むには、結局フェッチが要ります。手元の状態だけで完結させない、というのがここでの原則です。Antigravity に楽観的更新を書かせると、(1) と (5) のどちらかを落としがちなので、生成後はこの2手の有無を真っ先に見ます。
地点3: SSR ハイドレーション — QueryClient をリクエストごとに作る
Next.js App Router と組み合わせるとき、最も静かで最も危険なのがこの地点です。サーバーコンポーネントでプリフェッチした結果を、HydrationBoundary でクライアントに橋渡しするのが v5 の正攻法です。
// app/users/[id]/page.tsx — サーバーコンポーネント
import { dehydrate, HydrationBoundary, QueryClient } from "@tanstack/react-query" ;
import { UserPageClient } from "./UserPageClient" ;
import { queryKeys } from "@/lib/queryKeys" ;
export default async function Page ({ params } : { params : { id : string } }) {
const qc = new QueryClient (); // ← 必ずリクエストごとに新規生成
await qc. prefetchQuery ({
queryKey: queryKeys.users. profile (params.id),
queryFn : () => fetchUserProfileServer (params.id),
});
return (
< HydrationBoundary state = { dehydrate (qc) } >
< UserPageClient userId = { params.id } />
</ HydrationBoundary >
);
}
new QueryClient() を関数の中で呼んでいる点が肝です。Next.js のサーバーでは複数ユーザーのリクエストが同じプロセスで処理されます。ここでモジュールトップに QueryClient をシングルトンとして置くと、あるユーザーのプロフィールがキャッシュに残り、次のユーザーのレンダリングでそれが混ざります。つまり、別人の個人情報が画面に出る。これはパフォーマンスの話ではなく、本番運用で実際に情報漏洩になり得る設計事故です。ここは何度デバッグしても根が深い落とし穴なので、対処を仕組みで固定します。Antigravity に SSR 連携を書かせると、利便性のためにシングルトン化を提案してくることがあるので、ここは必ず人間が止めてください。
もう一点、サーバー側の fetchUserProfileServer とクライアント側の fetchUserProfile は、戻り値の型だけ共有して実装は分けるのが綺麗です。サーバーでは認証 Cookie の再構築や内部 RPC の直呼びが要ることがあり、両方を1関数に押し込むと isServer 分岐だらけになります。型を共有し、実装を分ける。この線引きが、後のデバッグを楽にします。
Suspense 境界は「狭く、複数に」割る
v5 で安定した useSuspenseQuery は、ローディングを親の <Suspense> に、失敗を <ErrorBoundary> に委ねます。if (isPending) の分岐がコンポーネントから消えるのは大きな利点ですが、境界の配置を誤ると「片方の読み込みで画面全体が消える」「回復ボタンを押してもエラーが残る」が起きます。
// 境界を子コンポーネント単位で狭く切る
< ErrorBoundary FallbackComponent = { ErrorFallback } >
< Suspense fallback = { < UserHeaderSkeleton /> } >
< UserHeader userId = { id } />
</ Suspense >
</ ErrorBoundary >
< ErrorBoundary FallbackComponent = { ErrorFallback } >
< Suspense fallback = { < UserPostsSkeleton /> } >
< UserPosts userId = { id } />
</ Suspense >
</ ErrorBoundary >
トップに巨大な境界を1つ置くと、ヘッダーと投稿のどちらか一方が待つだけで画面全体がスケルトンに沈みます。子単位で割れば、ヘッダーが見えたまま投稿だけが後から現れ、失敗の影響範囲も局所化されます。私は「データを取得するコンポーネントは、自分を <Suspense> で包む責任を持つ」という規約を採っています。コンポーネント生成プロンプトに「親で <Suspense> を期待しないこと」と一行入れておくと、生成コードの一貫性が目に見えて上がります。
無効化境界を「運用」で守る — AI への規約注入
設計が正しくても、チームや AI が増えると境界はじわじわ崩れます。これを防ぐ実務的な方法は、規約をドキュメントではなくコードとプロンプトに埋めることです。私の場合、.antigravity/rules 相当の指示に次の4点を固定で書いています。クエリキーは必ず queryKeys ファクトリ経由、queryFn は signal を受けて fetch に渡す、ミューテーションは cancelQueries と onSettled.invalidateQueries を両方持つ、useSuspenseQuery のコンポーネントは Suspense 境界の中に置く。
この4点を AI のルールに入れておくと、生成コードがそもそも規約に沿った形で出てくるので、レビューが「規約違反探し」から「設計判断の確認」に変わります。AI に任せてよいのはボイラープレートと既存パターンへの追従。自分で握るべきは、無効化の境界・Suspense の配置・楽観更新のロールバック経路・signal を伝播させるか、という設計判断です。この線引きさえ崩さなければ、生成速度を上げながら整合性を保てます。
次の一歩
もし明日1つだけ手を入れるなら、queryKeys ファクトリを1ファイル足し、既存の useQuery を1つだけそこ経由に書き換えてください。それだけで無効化の粒度が「キーの深さ」として見えるようになり、冒頭のような巻き戻りバグも「3地点のどれか」と問えるようになります。整合性の設計は、最初の1ファイルから静かに始まります。