🧩

バラバラだった staleTime を、データの性質で選ぶ4プリセットに集約した

に公開

バラバラだったキャッシュ設定を4プリセットに集約した
画面ごとにバラバラだった staleTime 設定を、データの性質で選ぶ4プリセットに集約した

はじめに

このブログは Dress Code Advent Calendar 2026/07 の 10本目の記事です。

こんにちは。ぷーじ(@yug1224)です。

約7,500ファイル規模の TypeScript フロントエンドで、新しい画面を作るたびに「この画面の staleTime は何分にするのが正解なんだっけ?」と手が止まっていました。この曖昧さを消したくて、TanStack Query のキャッシュ方針を整理しました。

この記事では、QUERY_PRESETS に集約した設計と、マルチテナントで気をつけた点をまとめます。

React + TanStack Query で画面が増えるほど staleTime の判断がバラけてきた、大規模フロントエンドを扱う方を想定しています。

なぜキャッシュ戦略を明文化したか

ある一覧画面では staleTime: 5 * 60 * 1000、別画面では FIVE_MINUTES 定数、さらに別画面では refetchOnWindowFocus: false。どれも「5分キャッシュ」のつもりで書かれているのに、タブ復帰時の挙動は画面ごとに微妙に違っていました。

一覧・詳細・集計・機微情報では最適な設定が違うはずなのに、判断基準がどこにも書かれておらず、いつの間にか暗黙知になっていました。マルチテナント SaaS としては、ログアウトや組織切替でキャッシュ破棄が統一されておらず、進行中リクエストとのレースで前セッションのデータが UI に残るリスクもありました。地味に怖い問題です。

なので、いきなり全部書き換えるのではなく、QUERY_PRESETSclearAllQueries を足して、触る画面から少しずつ寄せる方針にしました。既存の useQuery はそのまま動かせます。

Dress Code における TanStack Query の前提

Dress Code はチャットアプリのように秒単位で同期する必要はなく、むしろ 他のスタッフが裏でデータを更新する 前提で動いています。業務中はタブを開きっぱなしにすることも多いので、古いキャッシュのまま判断されるのは困る一方、重い集計まで毎回取り直すのももったいない。だから、タブ復帰時に必要な画面では最新を見せつつ、集計系だけキャッシュを長めに持つ、という使われ方が多いです。

グローバル QueryClient には retry とエラー集約だけ置き、staleTime は各画面のプリセットで決めています(@tanstack/react-query 5.76.0、React 18、Vite)。

v5 に移してから、設計の前に次の2点を押さえておくと整理しやすかったです。

1つ目は、refetchOnWindowFocus: truestale なときだけ refetch されること。fresh ならタブに戻っても何もしない、という挙動です。staleTime が決まればフォーカス復帰の挙動もだいたい決まるので、プリセット設計の土台になります。

2つ目は、v4 から isPending / isLoading の意味が変わっていること。初回ローディングは isPending を見るのが正で、!data だけでローダーを出すと、失敗時にスピナーが残り続けます。私たちは isPending → エラー → コンテンツの順で分岐しています。より細かい既定値は Important Defaults が詳しいです。

QUERY_PRESETS の4つと選び方

QUERY_PRESETS は共有の lib/query.ts に置いています。ここでいちばん大事にしたのは、画面名ではなく データの性質 で選ぶことです。画面単位で分ける案もありましたが、7,500ファイル規模だと「この画面は何分?」の会話が終わらない。そこで「このデータは listdaily か」と話せる方針を採用しました。

表の数値(5分・30分・当日残り)は、最初から絶対正解として決めたわけではありません。一覧の refetch 回数と体感速度のバランスを見ながら、チームで「まずここから試す」出発点として置いています。運用で詰まったら個別画面だけ上書きし、うまくいったらプリセット側に還元する、という運用です。4つに当てはまらない性質が出てきたら、5つ目のプリセットを足してもいいし、既存の時間を調整してもいい、と考えています。

プリセット staleTime gcTime 主な用途
list() 5分 30分 一覧・KPI・他ユーザー更新を拾いたい参照
daily() 当日残り 当日残り 重い集計・ほぼ不変の参照データ
detail() 0 5分 編集直前の単一エンティティ詳細
confidential() 0 0 個人情報(PII)・認証情報(Credential)など機微情報

4つのキャッシュプリセット一覧
list() / daily() / detail() / confidential() の4分類

export const QUERY_PRESETS = {
  list: () =>
    ({
      staleTime: FIVE_MINUTES,
      gcTime: THIRTY_MINUTES,
      refetchOnWindowFocus: true,
      refetchOnReconnect: true,
      refetchOnMount: true,
    }) as const,

  daily: () => {
    const untilEndOfToday = remainingTimeToday();
    return {
      staleTime: untilEndOfToday,
      gcTime: untilEndOfToday,
      refetchOnWindowFocus: true,
      refetchOnReconnect: true,
      refetchOnMount: true,
    } as const;
  },

  detail: () =>
    ({
      staleTime: 0,
      gcTime: FIVE_MINUTES,
      refetchOnWindowFocus: true,
      refetchOnReconnect: true,
      refetchOnMount: true,
    }) as const,

  confidential: () =>
    ({
      staleTime: 0,
      gcTime: 0,
      refetchOnWindowFocus: true,
      refetchOnReconnect: true,
      refetchOnMount: 'always',
    }) as const,
} as const;

検討したが採用しなかった案

4プリセットに至る前に、次の案も検討しました。見送った理由とあわせて表にまとめています。

概要 見送った理由
QueryClient のグローバル staleTime 全クエリに同じ既定値を載せる 一覧・詳細・機微情報で最適値が違い、結局画面ごとに上書きが増える
画面ごとの staleTime 直書き(現状維持) 各 hook が個別に秒数を決める 7,500ファイル規模で暗黙知化し、「5分」のつもりが挙動不一致になりやすい
queryKeyHashFn の導入 Query Key のハッシュ方法を QueryClient で統一する キャッシュ方針(stale / gc / focus)の問題は解決しない。キーの見え方が変わるだけで、マルチテナントでは organizationId を Key に明示する方が安全。デバッグ時に「どの Key が衝突したか」も追いにくくなる
Read Model 画面の常時ポーリング 再計算完了まで常に短間隔 refetch ドメイン数に比例して負荷が増え、大半の時間は不要な通信になる
4プリセット(採用) データの性質で list / daily / detail / confidential を選ぶ 判断軸が1つに集約でき、段階導入しやすい

queryKeyHashFn は Key の持ち方を整える API で、今回の本丸である「いつ fresh とみなすか」「いつ破棄するか」とはレイヤーが違います。oRPC の queryOptions() で Key 生成を揃えつつ、テナント境界は inputorganizationId を含めて明示する方針を取りました。

選び方のフロー

迷ったらまず list() から試し、重い集計や機微情報だけ daily() / confidential() に寄せる、という運用にしています。私の経験上、list() から外すのは慎重にした方が事故が少なかったです。

プリセットの選び方フロー
迷ったら list() から。機微情報・編集直前の詳細・重い集計で分岐する

list()daily() の見分け

daily という命名は、振り返ると最適だったかは少し疑問が残ります。実装しているのは「当日末まで fresh を保つ」という挙動です。名前は list と対になる短い語として一度定着したので、いまは改名コストと勘違いリスクを天秤にかけて維持しています。

list()daily() の見分けは、いちばん迷いやすい分岐でした。一度「バッチは走ったのに画面が古いままだ」と言われて調べたら、daily() の性質を勘違いしていた、というのを本番でも経験したことがあります。

要件は、だいたい次のどちらかに分かれます。

  • list(): 同日中に他ユーザーが更新した値を、タブ復帰時(5分経過後)に拾いたい
  • daily(): 重いダッシュボード集計など、当日中は再取得を抑えたい

問題になったのは、まさに前者を後者にしてしまったケースです。同日中のバッチ結果をタブ復帰だけで拾いたい画面に daily() を当てると、一日中取り直されません。daily()refetchOnWindowFocus: true でも 当日中は fresh のまま だからです。

同日中に拾いたいなら list() でした。

もう一段ハマりやすいのが日跨ぎです。observer(useQuery の購読)がマウントされたまま日付が変わっても、自動では refetch されません。夜間も開きっぱなしのダッシュボードで翌日のデータを見せたいなら、タブの unfocus→focus か remount、あるいはバッチ完了後の明示的な invalidateQueries が要ります。逆に言うと、当日中は再取得を抑えたい重い集計こそ daily() の出番で、次のように使います。

const { data } = useQuery({
  ...overview.queryOptions({ input: {} }),
  ...QUERY_PRESETS.daily(),
});

実装パターンと落とし穴

フィルタ操作中にタブを切り替えて戻ると一覧が揺れたり、編集モーダルを開いた瞬間に古いキャッシュでフォームが初期化されたりする。プリセットを足したあとも、そういう画面固有のハマりは残りました。

キャッシュまわりは次の3つで整理しています。

  1. QUERY_PRESETS(いつ fresh とみなすか。stale / gc / focus)
  2. invalidateQueries(更新後にどの Query Key を揺らすか)
  3. 一時ポーリング(Read Model の非同期再計算を、必要な間だけ追従する)

キャッシュ設計の全体像(プリセット・invalidate・一時ポーリング)
上: プリセット・invalidate・一時ポーリングの地図。下: データの性質の既定と、画面 UX の上書き。

基本的な使い方

新しい画面を触るたびに「feature 層と route 層、どっちにプリセットを足すんだっけ?」と迷っていたので、oRPC の queryOptions() に spread する形へ寄せました。一覧ドメインなら、feature 層はプリセットを足すだけで十分です。

export function useItemListQuery(input: ListInput) {
  return useQuery({
    ...api.item.list.queryOptions({ input }),
    ...QUERY_PRESETS.list(),
  });
}

一方 route 層では、フィルタ操作中にタブを切り替えて戻ると一覧が揺れる画面だけ、refetchOnWindowFocus を意図的に上書きしています。プリセットはデータの性質の既定、route の上書きは画面 UX の例外、という切り分けです。

export function useItemList(params: ListParams) {
  return useQuery({
    queryKey: itemKeys.list(params),
    queryFn: () => fetchItems(params),
    ...QUERY_PRESETS.list(),
    refetchOnWindowFocus: false,
  });
}

編集モーダルを開いた瞬間に古いキャッシュでフォームが初期化されるのが一番嫌なので、単一エンティティの詳細には detail()staleTime: 0)を当てています。

export function useItemDetail(id: string) {
  return useQuery({
    ...api.item.get.queryOptions({ input: { id } }),
    ...QUERY_PRESETS.detail(),
    enabled: id.length > 0,
  });
}

oRPC 未移行の openapi-fetch ドメインでも、同じ方針を適用できます。移行前はこう直書きしていて、

// 移行前
export function useLegacyList(params: ListParams) {
  return useQuery({
    queryKey: ['items', params],
    queryFn: () => fetchItems(params),
    staleTime: FIVE_MINUTES,
    refetchOnWindowFocus: false,
  });
}

触るタイミングで ...QUERY_PRESETS.list() に置き換えています。

// 移行後
export function useLegacyList(params: ListParams) {
  return useQuery({
    queryKey: ['items', params],
    queryFn: () => fetchItems(params),
    ...QUERY_PRESETS.list(),
    refetchOnWindowFocus: false,
  });
}

機微情報は confidential()

セキュリティ方針として、個人を特定できる情報(PII)や認証情報・秘密情報(Credential)には gcTime: 0refetchOnMount: 'always' を当て、クライアント側キャッシュの保持を最小化しています。transport 暗号化やサーバー側の権限チェックの代わりではなく、観測が終わったあと TanStack Query のキャッシュに残り続けないための層です。

return useQuery({
  queryKey: ['confidential-file', fileId],
  queryFn: () => fetchConfidentialFile(fileId),
  enabled: Boolean(fileId),
  ...QUERY_PRESETS.confidential(),
  retry: false,
  refetchOnWindowFocus: false,
});

detail() との違いは gcTime: 0refetchOnMount: 'always' の2点です。QUERY_PRESETS.confidential()refetchOnWindowFocus: true が既定ですが、機微ファイルの取得ではセキュリティ方針として意図的に false で上書きしています。それでも confidential() は万能ではありません。画面表示中はキャッシュがメモリに残るので、モーダル限定表示など、呼び出し側の設計とセットで考える必要があります。

Read Model の再計算はどう追うか

これは「ルールを保存したのにアラート件数がずっと古いままだ」という問い合わせから気づいたパターンです。Read Model は画面表示用に正規データから作る読み取り専用のデータで、ルール変更後にバックエンドが 非同期再計算 するドメインがあり、HTTP レスポンスが返った時点ではまだ古いことがあります。invalidateQueries だけでは、画面がすぐには追従しません。

かといって、この画面のためだけに常時ポーリングを回すのももったいない。ふだんは 通常モード でプリセットに任せ、再計算が走っている数十秒だけ 追従モード に切り替えたい。Mutation 成功後に 一時ポーリングをブースト し、Read Query 側では QUERY_PRESETS に加えて usePollingQuery で短間隔 refetch します。

usePollingQueryuseQuery の薄いラッパーで、polling.shouldPolltrue のときだけ refetchInterval を有効にします。本番では mutation 直後ブーストなども載せていますが、最小形は次のとおりです。

import { useQuery, type UseQueryOptions } from '@tanstack/react-query';

type PollingQueryOptions<TData> = {
  intervalMs?: number;
  shouldPoll: (data: TData | undefined) => boolean;
};

export function usePollingQuery<TData>({
  polling,
  ...queryOptions
}: UseQueryOptions<TData> & { polling: PollingQueryOptions<TData> }) {
  return useQuery({
    ...queryOptions,
    refetchInterval: (query) =>
      polling.shouldPoll(query.state.data as TData) ? (polling.intervalMs ?? 5_000) : false,
  });
}

プリセットと一時ポーリングの2モード
通常モードは QUERY_PRESETS、追従モードでは usePollingQuery が短間隔 refetch

// Mutation 側
onSuccess: async () => {
  await Promise.all([
    queryClient.invalidateQueries({ queryKey: ruleKeys.detail(ruleId) }),
    invalidateDomainQueries(),
  ]);
  startRefreshPolling();
};

// Read 側
const isBoosting = useRefreshPollingBoosting();

return usePollingQuery({
  ...api.alerts.list.queryOptions({ input }),
  ...QUERY_PRESETS.list(),
  enabled,
  polling: {
    shouldPoll: () => isBoosting,
  },
});

通常モードでは stale / gc / focus は QUERY_PRESETS が担い、追従モードでは再計算が終わるまでの間だけ usePollingQuery が短間隔 refetch します。

ハマったポイント

運用してみて、特に注意するようになったのは次の4つです。

useMemo でプリセットを固定しない

これは実際に一度ヒヤッとしたポイントです。オプションを useMemo(() => QUERY_PRESETS.daily(), []) で固定していたダッシュボードが、日付をまたいでもいつまでも fresh のまま refetch されない、という挙動になりました。原因は、daily()remainingTimeToday() が返す「当日末までの残り時間」を初回レンダーで固定してしまい、TanStack Query が登録時の staleTime を保持し続けていたことでした。

プリセットは毎レンダー ...QUERY_PRESETS.daily() と展開します。

daily() のタイムゾーンはブラウザローカル

daily() の中核は remainingTimeToday() です。date-fnsendOfToday() から現在時刻を引き、0 未満にならないよう Math.max で切り上げた当日末までのミリ秒を staleTime / gcTime に渡しています。

import { endOfToday } from 'date-fns';

function remainingTimeToday(): number {
  return Math.max(0, endOfToday().getTime() - Date.now());
}

ブラウザのローカル TZ 基準です。組織タイムゾーンとずれるテナントでは list() か明示的な invalidateQueries に寄せます。組織 TZ に連動させる案もありますが、まずはシンプルな実装から始めました。

spread の順序

プリセットを先に spread し、個別上書きはその後に書くのが基本です。順序を逆にすると、せっかくの上書き意図がプリセット側に潰されてしまいます。

// OK
useQuery({
  ...api.item.get.queryOptions({ input: { id } }),
  ...QUERY_PRESETS.detail(),
  refetchOnWindowFocus: false,
});

// NG: プリセットが後ろにあると staleTime 等が上書きされる
useQuery({
  ...api.item.get.queryOptions({ input: { id } }),
  staleTime: 0,
  ...QUERY_PRESETS.list(),
});

placeholderData: keepPreviousData は画面ごとに明示

placeholderData: keepPreviousData はページングやフィルタ切替のちらつき抑制に使えますが、検索0件時に前データが残ると業務判断を誤らせます。だからプリセットには含めず、必要な画面だけ明示的に足しています。hook 側では ...QUERY_PRESETS.list(), placeholderData: keepPreviousData と併用し、画面側では isPlaceholderData で薄いローディングを出す、という分担です。

export function useAlerts(params: AlertParams) {
  return useQuery({
    ...api.alerts.list.queryOptions({ input: params }),
    ...QUERY_PRESETS.list(),
    placeholderData: keepPreviousData,
  });
}
const { data, isPending, isPlaceholderData } = useAlerts(params);

<div className={cn(isPlaceholderData && 'opacity-50')}>
  <DataTable table={table} />
</div>;

セッション切替時の clearAllQueries

冒頭で「地味に怖い」と書いたセッション境界の話です。組織切替直後に一瞬だけ前テナントの行が見えた、という事象を経験してから、ここは特に気をつけるようになりました。再現条件を追うと、Query Key に organizationId が入っていなかった画面に限られていました。

まずやることは、Query Key に organizationId を含めることです。oRPC では input に含めれば Query Key に自動反映されるので、新規ドメインでは必須にし、既存ドメインは編集のタイミングで揃えています。それでも取りこぼしは怖いので、セッション境界では clearAllQueries を保険として重ねています。

組織切替時のキャッシュ破棄フロー
ログアウト・組織切替・セッション無効化のタイミングで clearAllQueries を呼び出す

export async function clearAllQueries(queryClient: QueryClient): Promise<void> {
  try {
    await queryClient.cancelQueries();
  } catch (error) {
    getLogger().warn('Failed to cancel queries before cache clear', { error });
    throw error;
  } finally {
    queryClient.clear();
  }
}

cancelQueries を先に呼ぶのは、進行中フェッチが clear 後に書き戻すレースを防ぐためです。finallyclear するので、cancel が失敗して throw してもキャッシュ破棄自体は試みます。呼び出し側で catch して後続処理を続けるのは、組織切替フローを止めないための判断で、organizationId を含む Query Key が主防御、clearAllQueries は保険 という位置づけです。

await switchOrganization(organizationId);

try {
  await clearAllQueries(queryClient);
} catch {
  // cancel 失敗時も finally で clear は済む。後続処理は続行する。
}

await refreshUserSession();
await downloadSignedCookies();
window.location.href = redirectTo;

ログアウト、組織切替(API 成功直後)、sign-in 時のセッション無効化の3経路から clearAllQueries を呼び出し、組織切替ではセッション再取得の前にキャッシュを破棄します。

Mutation 後はどこまで invalidate するか

プリセットで「いつ fresh とみなすか」を揃えても、更新後に古いキャッシュが残れば意味が薄れます。更新後にどの Query Key を揺らすかは、4プリセットとは別の整理になります。

引数なしの queryClient.invalidateQueries() は、一度全体が refetch されて重く感じられたため避けています。小規模なアプリなら割り切って使う選択肢もあると思います。

基本は影響範囲を小さくすることです。単一エンティティの更新なら対象の detail(id) と関連する lists() を invalidate し、ドメイン全体に波及するなら invalidateDomainQueries() のような集約ヘルパを使い、他ドメインへ波及するときは各ドメインの lists() を個別に invalidate します。

一覧・詳細・アラートをまとめて無効化する invalidateDomainQueries は、次のような形で用意しています。

export async function invalidateDomainQueries() {
  await Promise.all([
    queryClient.invalidateQueries({ queryKey: domainKeys.all }),
    queryClient.invalidateQueries({ queryKey: api.items.list.key() }),
    invalidateAlertQueries(),
  ]);
}

// 更新 Mutation の onSuccess
onSuccess: invalidateDomainQueries,

ルール保存のように、個別 key の invalidate・集約ヘルパ・ポーリングブーストを組み合わせるケースもあります。

楽観的更新も同じで、setQueryData で手元を先回りしたあと、onSettledinvalidateQueries して真値と再同期しておくのが確実です。背景タブまで一斉に refetch させたくないときは、refetchType: 'active' を添えると無駄打ちを抑えられます。

運用では、新規改修のたびにプリセットを spread するようになりました。残っている staleTime 直書きは十数ファイル程度で、いきなり全置換はしません。その画面を触るタイミングで少しずつ寄せています。一括置換しないのは、refetchOnWindowFocus を意図的に上書きしている画面があり、機械的に置き換えると挙動が変わってしまうからです。

まとめ

冒頭で書いた「新しい画面のたびに手が止まる」という状態は、少なくとも新規画面ではほぼなくなりました。4プリセットに集約したことで、「この画面は何分 stale でいいか」ではなく「このデータは listdaily か」と話せるようになり、staleTime を一から決め直す頻度は体感ではかなり減りました。完全移行はまだ途中で、直書きの残りも十数ファイルあります。それでも、判断のコストは下がったと感じています。

自社でも導入するなら、いきなり全画面を直すより データの性質で3〜4分類する ところから始めるのが現実的です。Read Model の非同期再計算があるドメインでは、通常モードのプリセットに 追従モードの一時ポーリング を足す構成が有効でした。

同じ悩みを持っている方の参考になれば嬉しいです。daily の命名や分類の経験、「こうハマった」といった話があれば、教えてもらえるとうれしいです。

DRESS CODE TECH BLOG

Discussion