🗂️

自前キャッシュを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]);
}

やっていることを整理すると:

  1. キャッシュ(memoryCache)にデータがあれば即返す
  2. なければ inFlight を確認して、進行中なら Promise を共有、なければ新しくfetch
  3. 完了したら memoryCache に保存
  4. latestNoRef でホテルを切り替えたときの競合(古いデータが後から届く問題)を防ぐ
  5. 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引数 optscache: "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({...});

動作確認

DevToolsのNetworkタブ。hotel-detailへのリクエストが走り、X-Vercel-CacheがSTALEになっている
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 への移行を検討する価値はあると思います。


GitHubで編集を提案

Discussion