1. 序論:サーバー状態(Server State)はいつ古くなるのか
フロントエンドを運用していると、ユーザーから繰り返し寄せられる報告が二つあります。一つは「たった今登録したのに一覧に表示されません」で、もう一つはその反対の「何もしていないのに画面が突然ちらついて、再び読み込まれました」です。一見すると無関係に思えるこの二つの症状は、実は同じ根本原因から生じています。それは、サーバーのデータをクライアントが「どのくらいの間、どのような基準で、まだ有効だと信じるのか」というキャッシュ(Cache)ポリシーの欠如です。
多くのチームがこの問題をReact Query(現在のTanStack Query)で解決しており、私が所属するプロジェクトも同様です。しかし実際にコードを見てみると、最も重要な「キャッシュをどのくらい保持するか」についての合意が一か所に集約されておらず、個々のフックに分散しているケースが非常に多くあります。
React Queryの価値は、「サーバー状態(Server State)」と「クライアント状態(Client State)」を区別するところから始まります。モーダルが開いているかどうかのような値は画面が所有するクライアント状態ですが、一覧・詳細・ユーザー情報のように元データがサーバーにあるものは、私たちが一時的にコピーを借りて使っているにすぎません。このコピーは、私が見ている間にもサーバー上で変更される可能性があります。これをuseState + useEffectで手作業で扱うと、ローディングやエラーのフラグを自分で管理する必要があり、同じデータを複数の画面から呼び出すとリクエストがそのまま重複します。React Queryは、queryKeyという名前札でレスポンスをキャッシュに保存し、この繰り返し処理を代わりに行います。結局のところ、「キャッシュをどのように管理するか」こそが、このライブラリをうまく使う本質です。
2. 核心概念:新鮮さ(fresh)と古さ(stale)、そしてキャッシュの寿命
2.1 staleTime — データの「賞味期限」
React Queryのすべてのデータは、新鮮(fresh)か古い(stale)かのどちらかです。取得したばかりのデータはfreshで、staleTimeを過ぎるとstaleに変わります。食品の賞味期限にたとえると分かりやすいでしょう。重要なのは、staleになってもデータを破棄するわけではないという点です。賞味期限が切れた食品もひとまず食卓には出しておき、「次に買い物へ行く機会があれば新しいものに交換する」という印だけを付けるようなものです。そして、もう一つ落とし穴があります。staleTimeのデフォルト値は0なので、データは取得した直後にすぐstaleになります。
2.2 gcTime — キャッシュの「倉庫保管期間」
あるクエリを使用していたコンポーネントが画面から消えると、そのクエリは非アクティブ(inactive)状態になります。gcTimeは、この非アクティブなキャッシュをメモリから消去するまで待つ時間で、デフォルト値は5分です。倉庫での保管期間に相当します。この時間内に同じ画面へ戻ればキャッシュが残っているためすぐに表示できますが、時間を過ぎるとガベージコレクション(Garbage Collection)の対象となり、次回は最初から再取得します。
2.3 再リクエスト(refetch)はいつ発生するのか
staleなデータは、四つのタイミングで再取得されます。コンポーネントが新しくマウントされたとき、ブラウザウィンドウに再びフォーカスが当たったとき、切断されていたネットワークが再接続されたとき、そして私たちが明示的に無効化(Invalidation)を呼び出したときです。ただし、これらすべてのトリガーはデータがstaleのときにのみ機能します。freshであれば、ウィンドウを何度出入りしても再リクエストは発生しません。したがって、「どのくらいの頻度で再取得するか」の大部分は、staleTimeをどのように設定するかで決まります。
|
区分 |
staleTime |
gcTime |
|---|---|---|
|
問いかける内容 |
どのくらい新鮮か |
どのくらい保管するか |
|
デフォルト値 |
0(即時にstale) |
5分 |
|
影響範囲 |
再リクエスト(refetch)のタイミング |
メモリ使用量/再訪時の速度 |
|
期間が過ぎても |
データは画面に保持される |
キャッシュ自体が削除される |
3. よくあるキャッシュのアンチパターン
3.1 全体的な基準なしにstaleTimeをフックごとにばらばらに設定する
全体のデフォルト値を定めないと、staleTimeは0になります。そのため、画面に入り直すたび、コンポーネントが再びマウントされるたびに、同じリクエストがまた送信されます。これに遅れて気づいた開発者が、画面ごとにstaleTimeを手作業で一、二か所に設定し始めますが、ある人は1分、ある人は5分、多くは未設定のまま残ります。値の根拠がないため、新しく加わった人は「このデータはどのくらいの頻度で更新されるべきなのか」を判断する基準を失います。
3.2 取得キーと無効化キーが食い違う
最もよくある一方で、見つけにくい実際のバグです。たとえば、記事一覧を以下のように一つのキーでキャッシュしておき、登録後には関係のないキーを無効化するとします。
// 목록은 이 키로 캐싱했는데
useQuery({ queryKey: ['posts'], ... })
// 글 등록 후엔 다른 키를 무효화한다면
queryClient.invalidateQueries({ queryKey: ['postList'] }); // 매칭 안 됨 → 목록 그대로
二つのキーは文字列が異なるためマッチせず、一覧のキャッシュはそのまま残ります。登録は成功したのに、画面が更新されないのです。コンパイラーは、この二つが同じデータを指しているかどうかを判断できないため、警告すら表示できません。些細なことに見えますが、キーを文字列で手入力する限り、どのプロジェクトでも繰り返されるミスです。実際、私が見たコードの中には、同じファイル内で削除時には正しいキーを使い、登録時には関係のないキーを無効化していたため、「削除はできるのに登録内容が画面に表示されない」という微妙なバグが残っていたケースもありました。
3.3 キャッシュ外の手動フェッチが共存する
一部の画面ではReact Queryを使い、別の画面ではいまだにuseStateとuseEffectで同じデータを取得しているケースです。このようになると、一方にしかキャッシュがないためデータに食い違いが生じる可能性があり、重複排除や自動更新のメリットもその画面では失われます。特に、移行が完了していないコードベースでよく見られます。
4. 効果的なキャッシュ運用戦略
4.1 まず全体のデフォルト値(Default Options)について合意する
最初にすべきことは、QueryClientのdefaultOptionsにキャッシュの基準線を明記することです。staleTimeを0ではない妥当な値に設定し、refetchOnWindowFocusは全体で無効にしたうえで、リアルタイム性が本当に必要な画面でのみ例外的に有効にする方法を推奨します。この一度の設定で3.1が解消され、個々のフックで同じオプションを繰り返し記述する必要もなくなります。
new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 30, // 기준선: 30초 동안은 신선하다고 본다
refetchOnWindowFocus: false, // 기본은 끄고, 필요한 화면만 켠다
retry: 1, // 실패 시 과한 재시도 방지
},
},
});
数値そのものより重要なのは、「基準線がコードの一か所に存在する」という事実です。これで個々のフックは、基準線から変更する必要がある場合にだけオプションを上書きすればよくなり、その上書き箇所自体が「このデータは特別である」というシグナルになります。
4.2 データの性質でstaleTimeを分類する
「staleTimeを何分にするか」を画面ごとに決めるべきではありません。基準にすべきなのは画面ではなく、データがどのくらいの頻度で変化するかです。データを三つの種類に分けておけば、新しいクエリを作るときの迷いがなくなります。マスターデータのようにほとんど変化しない値はstaleTimeを長く(場合によってはInfinityに)設定し、その代わりに「値が実際に変化した瞬間に直接無効化する」という約束も併せて持つのが効率的です。そうすれば、通常はネットワークをまったく使わず、本当に変化したときだけ一度更新できます。gcTimeは通常、デフォルトの5分で十分ですが、キオスクや組み込み環境のように一つの画面に長時間とどまるフローであれば延長して「戻ってきたら再びローディングされる」ことを避け、メモリに余裕がない環境であれば短くするなど、意識的に調整します。
|
分類 |
例 |
推奨 staleTime |
|---|---|---|
|
マスター系(ほぼ不変) |
カテゴリ、コードテーブル、組織/部署構造 |
5~10分以上+変更時に直接無効化 |
|
準静的(たまに変わる) |
自分のプロフィール、権限・設定 |
1~3分 |
|
トランザクション系(頻繁に変わる) |
注文/投稿一覧、通知、在庫 |
0~30秒 |
4.3 クエリキーは「ファクトリー」で一元化する
3.2のキー不一致バグを構造的になくす方法は単純です。キーを手で記述せず、キーを作成する関数(ファクトリー)を通すのです。階層的に設計しておけば、無効化がはるかに強力になります。
export const postKeys = {
all: ['posts'] as const,
lists: () => [...postKeys.all, 'list'] as const,
list: (filters) => [...postKeys.lists(), filters] as const,
detail: (id) => [...postKeys.all, 'detail', id] as const,
};
ここで、知っておくとよいメカニズムが一つあります。 invalidateQueriesはキーの「部分一致(prefix match)」で動作します。つまり invalidateQueries({ queryKey: postKeys.all }) の1行だけで、['posts','list', ...]としてキャッシュされたすべての一覧と詳細が一括して対象になります。逆に特定の投稿1件だけを更新したい場合は、postKeys.detail(id)だけを無効化すればよいのです。取得にも無効化にも同じファクトリーからキーを取り出して使うため、文字が食い違うことが根本的になくなります。キーを1か所でのみ定義するので、自動補完や型チェックの助けも得られます。
4.4 無効化だけが答えではない:直接更新と楽観的更新
無効化(invalidate)とは、「このキャッシュは古くなったので、もう一度取得せよ」という指示です。安全ですが、ネットワーク リクエストをもう1回発生させます。しかし、変更APIのレスポンスにすでに最新データが含まれているなら、わざわざ再取得する必要はありません。setQueryDataでキャッシュを直接入れ替えるほうが、速く効率的です。リクエスト1回分を丸ごと節約できるわけです。
さらに一歩進めて、いいねの切り替えやチェックボックスのように即座の反応が重要な操作には、楽観的更新(Optimistic Update)を使います。 onMutate の段階でサーバーのレスポンスを待たずにキャッシュを先に変更して画面を即座に更新し、もしリクエストに失敗したら以前の値にロールバックする方法です。ユーザーは遅延をほとんど感じません。まとめると、「再取得する(invalidate)→レスポンスで入れ替える(setQueryData)→先に変更し、失敗時に戻す(optimistic)」の順で、即時性が重要であるほど右側を選べばよいのです。
4.5 切り替えをスムーズにする:keepPreviousDataとplaceholderData
ページネーションや検索のように、同じ画面でパラメーターだけが変わる場合、キーが変わるたびに画面が空の状態でちらつき、スピナーが回ります。このとき placeholderData: keepPreviousDataを指定すると、新しいデータが届くまで前のページのデータをそのまま表示できます。表が消えてから再び現れるのではなく、自然に次のページへ移行できます。一覧・検索UIの体感品質を最も低コストで引き上げる方法であり、すでにうまく使えているなら、ほかの一覧にも広げることをおすすめします。
4.6 待ち時間を先に隠す:prefetchとinitialData
ユーザーが次に何を見るか予測できるなら、そのデータをあらかじめ取得しておけます。一覧である項目にマウスを乗せた瞬間に prefetchQueryで詳細を先に取得しておけば、実際にクリックしたときにはすでにキャッシュにあるため、即座に開けます。逆に、すでに手元にデータがある場合(一覧レスポンスに詳細の一部が含まれている場合や、SSRでダウンロード済みの場合)は、initialDataでキャッシュの初期値を埋め、初回ローディングを省略できます。どちらも「待ち時間をユーザーの目に触れない場所へ移す」テクニックです。
4.7 キャッシュを目で見る:Devtools
最後に、開発ビルドにのみ @tanstack/react-query-devtoolsを追加することを強くおすすめします。各クエリがfresh・stale・inactiveのどの状態にあるのか、無効化が実際に反映されたのか、どのキーがいくつ存在しているのかを図で表示してくれます。3.2のようなキーの不一致は、Devtoolsを一度開いて見るだけですぐに明らかになります。推測でデバッグしていたキャッシュの問題を、目で確認できる問題へと変えてくれます。
5. 結論
React Queryをうまく使うということは、華やかな機能を数多く使うことではなく、キャッシュに関する約束事をコードの1か所に明文化することに近いものです。第一にグローバルなデフォルト値を設定して基準線を作り、第二にデータの性質に応じて staleTime を分類し、第三にクエリキーをファクトリーに集約します。この3つだけでも、「登録したのに一覧が変わらない」「画面を移動するたびにちらつく」といった症状の大半がなくなります。
その上にもう一層加えるのが効率化です。すべての変更を無効化で処理して毎回再取得する代わりに、レスポンスでキャッシュを直接更新し(setQueryData)、即時性が必要な場所では楽観的更新で処理し、予測可能な次の画面はあらかじめ取得しておきます(prefetch)。キャッシュを「いつ空にするか」という問題ではなく、「いつ、どのように満たすか」という問題として捉え始めると、同じサーバー、同じネットワーク上でも、はるかに速く静かに動作する画面を作れるようになります。結局のところ、優れたキャッシュ戦略とは、ユーザーに待ち時間を感じさせないことです。
参考資料
-
TanStack Query – Important Defaults(デフォルト値のまとめ) — https://tanstack.com/query/latest/docs/framework/react/guides/important-defaults
-
TanStack Query – Caching メカニズム — https://tanstack.com/query/latest/docs/framework/react/guides/caching
-
TanStack Query – クエリの無効化 — https://tanstack.com/query/latest/docs/framework/react/guides/query-invalidation
-
TanStack Query – 楽観的更新 — https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates
-
TanStack Query – プリフェッチ — https://tanstack.com/query/latest/docs/framework/react/guides/prefetching
-
TkDodo – 効果的な React Query キー — https://tkdodo.eu/blog/effective-react-query-keys
-
TkDodo – 実践的な React Query — https://tkdodo.eu/blog/practical-react-query
toffeeman