自前キャッシュをTanStack Queryに移行したらコードが半分になった
はじめに

本サイトの地図画面には、ホテルのマーカーをタップすると詳細情報をAPIから取得して表示する機能があります。
この機能、最初は自分でキャッシュを実装していました。同じホテルを2回タップしたときにAPIを叩かないように、Map オブジェクトを使ってメモリに保存していたのです。
動いてはいました。でも、コードを見るたびに「これ、本当に正しく動いているのか?」という不安がありました。今回 TanStack Query(React Query)に移行したら、そのコードが丸ごと消えました。
移行前のコード——自前で全部やっていた
src/hooks/hotels/useHotelDetail.ts の中身です。
// メモリキャッシュ(同じホテル再閲覧の高速化)
const memoryCache = new Map<number, HotelDetail>();
// 同時リクエストの集約
const inFlight = new Map<number, Promise<HotelDetail>>();
ファイルのトップレベルに2つの Map が定義されていました。
memoryCache は「一度取得したホテルの詳細データを保存しておく箱」です。同じホテルをタップしたとき、まずここを見てデータがあればAPIを叩かずに返します。
inFlight は「今まさに進行中のリクエスト」を保存する箱です。同じホテルに対して2つのリクエストが同時に走るのを防ぐための仕組みで、「すでにリクエスト中なら、その Promise を共有する」という処理をしていました。
export function useHotelDetail(hotelNo: number | null) {
const [detail, setDetail] = useState<HotelDetail | null>(null);
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState<boolean>(false);
// 競合回避用に最新 hotelNo を保持
const latestNoRef = useRef<number | null>(null);
useEffect(() => {
latestNoRef.current = hotelNo;
}, [hotelNo]);
useEffect(() => {
// キャッシュ命中なら即返す
const cached = memoryCache.get(normalizedNo);
if (cached) {
setDetail(cached);
setLoading(false);
return;
}
const controller = new AbortController();
const signal = controller.signal;
// 進行中の同一リクエストがあればそれを共有
const existing = inFlight.get(normalizedNo);
const p = existing ?? fetchDetail(normalizedNo, signal);
if (!existing) inFlight.set(normalizedNo, p);
p.then((data) => {
if (latestNoRef.current !== normalizedNo) return; // 競合チェック
memoryCache.set(normalizedNo, data);
setDetail(data);
setLoading(false);
})
.catch((e) => {
if (signal.aborted) return;
if (latestNoRef.current !== normalizedNo) return;
setError(e instanceof Error ? e : new Error(String(e)));
setLoading(false);
})
.finally(() => {
if (!existing) inFlight.delete(normalizedNo);
});
return () => controller.abort();
}, [normalizedNo]);
}
やっていることを整理すると:
- キャッシュ(
memoryCache)にデータがあれば即返す - なければ
inFlightを確認して、進行中なら Promise を共有、なければ新しくfetch - 完了したら
memoryCacheに保存 -
latestNoRefでホテルを切り替えたときの競合(古いデータが後から届く問題)を防ぐ -
AbortControllerでアンマウント時のリクエストをキャンセル
動いてはいましたが、コードが複雑で「この latestNoRef のチェック、本当に全パスで正しいか?」という不安が常にありました。
インストール
pnpm add @tanstack/react-query
これだけです。
Step 1:QueryClientProvider をルートに追加
TanStack Query はアプリ全体で QueryClient を共有します。このプロジェクトでは src/components/ClientProviders.tsx にすべての Provider が集まっているので、ここに追加しました。
変更前
'use client'
import { ThemeProvider } from "next-themes";
export default function ClientProviders({ children }) {
return (
<ThemeProvider ...>
{children}
</ThemeProvider>
);
}
変更後
'use client'
import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "next-themes";
export default function ClientProviders({ children }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5分
retry: 1,
},
},
}),
);
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider ...>
{children}
</ThemeProvider>
</QueryClientProvider>
);
}
なぜ useState で生成するのか
QueryClient をコンポーネントの外で const queryClient = new QueryClient() と書くと、モジュールが読み込まれたタイミングで1度だけ生成されたシングルトンになります。サーバーサイドではこのインスタンスが全リクエストで共有されるため、ユーザーAのキャッシュがユーザーBのレスポンスに混入するデータ漏えいが起こります。useState(() => new QueryClient()) にしておくと、リクエストごとに独立したインスタンスが生成されるため、ユーザー間でキャッシュが分離されます。
staleTime: 1000 * 60 * 5 とは
「5分間はキャッシュを新鮮とみなし、再fetchしない」という設定です。ホテルの詳細情報は数分で変わるものではないので、5分はちょうどよい値でした。
Step 2:useHotelDetail.ts を useQuery に書き換え
ここがメインの変更です。
変更前
// ファイルトップレベルの自前キャッシュ
const memoryCache = new Map<number, HotelDetail>();
const inFlight = new Map<number, Promise<HotelDetail>>();
export function useHotelDetail(hotelNo: number | null) {
const [detail, setDetail] = useState<HotelDetail | null>(null);
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const latestNoRef = useRef<number | null>(null);
// ...キャッシュ確認・inFlight管理・競合チェック・AbortControllerの手動管理
}
変更後
import { useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
export function useHotelDetail(hotelNo: number | null) {
const queryClient = useQueryClient();
const normalizedNo = useMemo(() => {
if (hotelNo == null) return null;
const n = Number(hotelNo);
return Number.isFinite(n) ? n : null;
}, [hotelNo]);
const { data: detail, isLoading: loading, error } = useQuery({
queryKey: ["hotelDetail", normalizedNo],
queryFn: ({ signal }) => fetchDetail(normalizedNo!, signal),
enabled: normalizedNo != null,
});
const refresh = async () => {
if (normalizedNo == null) return;
await queryClient.invalidateQueries({ queryKey: ["hotelDetail", normalizedNo] });
};
return {
detail: detail ?? null,
loading,
error: error instanceof Error ? error : null,
refresh,
};
}
削除されたコードと、その代わりに何が担っているかの対応です。
| 削除したコード | TanStack Query が代わりに担うこと |
|---|---|
memoryCache = new Map() |
queryKey ごとに自動でキャッシュ。staleTime 内なら再fetchしない |
inFlight = new Map() |
同じ queryKey への同時リクエストを自動で1本に集約 |
latestNoRef |
queryKey が変わると自動で前のクエリを無効化 |
AbortController の手動管理 |
queryFn の引数 signal に自動で渡される |
fetchDetail 関数自体も少し変わっています。移行前は第3引数 opts で cache: "no-store" を切り替える仕組みがありました。
// 移行前
async function fetchDetail(
hotelNo: number,
signal?: AbortSignal,
opts?: FetchDetailOptions, // ← refresh() 時に no-store を渡すための引数
): Promise<HotelDetail> {
const res = await fetch(`...`, {
cache: opts?.noStore ? "no-store" : "default",
signal,
});
}
移行後は refresh() が invalidateQueries でキャッシュを無効化するようになったため、no-store を渡す必要がなくなり、opts ごと削除できました。invalidateQueries はマウント中のクエリに対して自動でrefetchを発火するため、refetch() を別途呼ぶ必要もありません。signal の受け取り方も queryFn の引数から受け取る形に変わっています。
// 変更後(queryFn の引数から signal を受け取る)
queryFn: ({ signal }) => fetchDetail(normalizedNo!, signal),
また、useQuery が返す値の名前は isLoading ですが、既存のコンポーネントは loading という名前で受け取っています。分割代入時に読み替えることで、呼び出し側のコンポーネントは一切変更不要でした。
const { data: detail, isLoading: loading, error } = useQuery({...});
動作確認

X-Vercel-Cache: STALE は Vercel の CDN キャッシュがあるが期限切れと判断され、バックグラウンドでオリジンへの再取得が走った状態です(stale-while-revalidate)。次にサーバーへリクエストが届いた際は HIT になります。TanStack Query がクライアントキャッシュから返す場合はリクエスト自体が発生しないため、DevTools には何も表示されません。
ブラウザの DevTools(Network タブ)を開いてホテルマーカーをタップします。/api/rakuten/hotel-detail?hotelNo=xxxxx へのリクエストが1回走ります。
同じマーカーをもう一度タップすると、リクエストが走りません。キャッシュから即座に表示されます。
5分後に再タップすると、もう1回リクエストが走ります。staleTime の5分が経過して「古いデータ」と判断されたためです。
削除できたコードの量
| 移行前 | 移行後 | |
|---|---|---|
| ファイルの行数 | 176行 | 82行 |
| トップレベルの Map | 2個 | 0個 |
| useRef | 1個 | 0個 |
| useState | 3個 | 0個 |
| useEffect | 2個 | 0個 |
半分以下になりました。そして残ったコードは「何をするか」だけが書いてあり、「どうやるか」は TanStack Query に任せています。
「動いてはいるけど不安」というコードを抱えているなら、TanStack Query への移行を検討する価値はあると思います。
Discussion