バラバラだった staleTime を、データの性質で選ぶ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_PRESETS と clearAllQueries を足して、触る画面から少しずつ寄せる方針にしました。既存の useQuery はそのまま動かせます。
Dress Code における TanStack Query の前提
Dress Code はチャットアプリのように秒単位で同期する必要はなく、むしろ 他のスタッフが裏でデータを更新する 前提で動いています。業務中はタブを開きっぱなしにすることも多いので、古いキャッシュのまま判断されるのは困る一方、重い集計まで毎回取り直すのももったいない。だから、タブ復帰時に必要な画面では最新を見せつつ、集計系だけキャッシュを長めに持つ、という使われ方が多いです。
グローバル QueryClient には retry とエラー集約だけ置き、staleTime は各画面のプリセットで決めています(@tanstack/react-query 5.76.0、React 18、Vite)。
v5 に移してから、設計の前に次の2点を押さえておくと整理しやすかったです。
1つ目は、refetchOnWindowFocus: true は stale なときだけ refetch されること。fresh ならタブに戻っても何もしない、という挙動です。staleTime が決まればフォーカス復帰の挙動もだいたい決まるので、プリセット設計の土台になります。
2つ目は、v4 から isPending / isLoading の意味が変わっていること。初回ローディングは isPending を見るのが正で、!data だけでローダーを出すと、失敗時にスピナーが残り続けます。私たちは isPending → エラー → コンテンツの順で分岐しています。より細かい既定値は Important Defaults が詳しいです。
QUERY_PRESETS の4つと選び方
QUERY_PRESETS は共有の lib/query.ts に置いています。ここでいちばん大事にしたのは、画面名ではなく データの性質 で選ぶことです。画面単位で分ける案もありましたが、7,500ファイル規模だと「この画面は何分?」の会話が終わらない。そこで「このデータは list か daily か」と話せる方針を採用しました。
表の数値(5分・30分・当日残り)は、最初から絶対正解として決めたわけではありません。一覧の refetch 回数と体感速度のバランスを見ながら、チームで「まずここから試す」出発点として置いています。運用で詰まったら個別画面だけ上書きし、うまくいったらプリセット側に還元する、という運用です。4つに当てはまらない性質が出てきたら、5つ目のプリセットを足してもいいし、既存の時間を調整してもいい、と考えています。
| プリセット | staleTime | gcTime | 主な用途 |
|---|---|---|---|
list() |
5分 | 30分 | 一覧・KPI・他ユーザー更新を拾いたい参照 |
daily() |
当日残り | 当日残り | 重い集計・ほぼ不変の参照データ |
detail() |
0 | 5分 | 編集直前の単一エンティティ詳細 |
confidential() |
0 | 0 | 個人情報(PII)・認証情報(Credential)など機微情報 |

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 生成を揃えつつ、テナント境界は input に organizationId を含めて明示する方針を取りました。
選び方のフロー
迷ったらまず 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つで整理しています。
-
QUERY_PRESETS(いつ fresh とみなすか。stale / gc / focus) -
invalidateQueries(更新後にどの Query Key を揺らすか) - 一時ポーリング(Read Model の非同期再計算を、必要な間だけ追従する)

上: プリセット・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: 0 と refetchOnMount: '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: 0 と refetchOnMount: 'always' の2点です。QUERY_PRESETS.confidential() は refetchOnWindowFocus: true が既定ですが、機微ファイルの取得ではセキュリティ方針として意図的に false で上書きしています。それでも confidential() は万能ではありません。画面表示中はキャッシュがメモリに残るので、モーダル限定表示など、呼び出し側の設計とセットで考える必要があります。
Read Model の再計算はどう追うか
これは「ルールを保存したのにアラート件数がずっと古いままだ」という問い合わせから気づいたパターンです。Read Model は画面表示用に正規データから作る読み取り専用のデータで、ルール変更後にバックエンドが 非同期再計算 するドメインがあり、HTTP レスポンスが返った時点ではまだ古いことがあります。invalidateQueries だけでは、画面がすぐには追従しません。
かといって、この画面のためだけに常時ポーリングを回すのももったいない。ふだんは 通常モード でプリセットに任せ、再計算が走っている数十秒だけ 追従モード に切り替えたい。Mutation 成功後に 一時ポーリングをブースト し、Read Query 側では QUERY_PRESETS に加えて usePollingQuery で短間隔 refetch します。
usePollingQuery は useQuery の薄いラッパーで、polling.shouldPoll が true のときだけ 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,
});
}

通常モードは 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-fns の endOfToday() から現在時刻を引き、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 後に書き戻すレースを防ぐためです。finally で clear するので、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 で手元を先回りしたあと、onSettled で invalidateQueries して真値と再同期しておくのが確実です。背景タブまで一斉に refetch させたくないときは、refetchType: 'active' を添えると無駄打ちを抑えられます。
運用では、新規改修のたびにプリセットを spread するようになりました。残っている staleTime 直書きは十数ファイル程度で、いきなり全置換はしません。その画面を触るタイミングで少しずつ寄せています。一括置換しないのは、refetchOnWindowFocus を意図的に上書きしている画面があり、機械的に置き換えると挙動が変わってしまうからです。
まとめ
冒頭で書いた「新しい画面のたびに手が止まる」という状態は、少なくとも新規画面ではほぼなくなりました。4プリセットに集約したことで、「この画面は何分 stale でいいか」ではなく「このデータは list か daily か」と話せるようになり、staleTime を一から決め直す頻度は体感ではかなり減りました。完全移行はまだ途中で、直書きの残りも十数ファイルあります。それでも、判断のコストは下がったと感じています。
自社でも導入するなら、いきなり全画面を直すより データの性質で3〜4分類する ところから始めるのが現実的です。Read Model の非同期再計算があるドメインでは、通常モードのプリセットに 追従モードの一時ポーリング を足す構成が有効でした。
同じ悩みを持っている方の参考になれば嬉しいです。daily の命名や分類の経験、「こうハマった」といった話があれば、教えてもらえるとうれしいです。
Discussion