TanStack Query v4 の mutation reset() は表示状態のみを戻し、cache 本体の秘密データは残る
useMutation の reset() は、UI 表示に使う result(data/error/status)を初期状態に戻すだけで、QueryClient の MutationCache に保持されている該当 mutation エントリ自体(state.data を含む)を削除しない。cache からの削除は「その mutation を購読する observer 数が 0 になったタイミング」でのみトリガーされる(cacheTime を 0 にしても、削除条件は observer 数依存であり cacheTime だけでは解決しない)。
問題になる場面
発行された API キー・ワンタイムトークン・パスワードリセット結果など、「画面には一度だけ表示し、閉じたら二度と見せない」設計の mutation で顕在化する。UI 上は表示が消えていても、queryClient.getMutationCache().getAll() で調べると該当 mutation の state.data に秘密値が残っている。DevTools やメモリダンプ、あるいは同一 QueryClient を共有する別コンポーネントから閲覧可能な状態が続く。
observer が 0 にならない典型パターン
モーダルなど開閉可能な UI で、親コンポーネントが「表示コンテンツを常にマウントしたまま開閉状態だけを CSS/ライブラリ側の可視性制御に委ねる」実装だと、useMutation を呼ぶ子コンポーネントがアンマウントされず observer が生き続ける。特にモーダルライブラリ(Radix Dialog 等)は、閉じた瞬間にコンテンツを実際に unmount する保証がない(クローズアニメーション用の Presence 機構に依存する実装がある)。ライブラリの可視性制御だけに頼らず、呼び出し側で open 相当のフラグを条件にコンテンツの描画自体を分岐させ、明示的にアンマウントさせることで observer を確実に切り離せる。
対策
- 該当 mutation に mutationKey を付け、明示的なクリア関数を用意する: queryClient.getMutationCache().findAll({ mutationKey }).forEach((m) => mutationCache.remove(m))。reset() の代わりにこれを close 経路(明示ボタン、モーダルの背景クリック、ページ遷移によるアンマウント)すべてから呼ぶ。
- cacheTime(v5 では gcTime)を 0 に設定するのは保険として有効だが、observer が残っている限り GC はトリガーされないため、これだけでは不十分。
- アンマウント保証がないコンテナ(モーダル等)を使う場合は、親側で「開いているときだけ内容を描画する」ように内容の描画自体を条件分岐させ、ライブラリの内部実装に依存しない確実なアンマウントを作る。
検証方法
閉じるボタン、背景クリック(またはコンテナ固有の外側操作)、コンポーネント全体の unmount のそれぞれの経路で、画面上の表示だけでなく queryClient.getMutationCache().getAll() を直接検査し、該当データが残っていないことをテストする。表示が消えていることの確認だけでは cache 上の残留を検出できない。
モーダルライブラリが「モーダル表示中は body 全体の pointer-events を無効化する」実装の場合、背景クリックを再現する自動テストでは通常のクリックシミュレーション(要素の pointer-events を検査して失敗する)が使えないことがある。その場合は低レベルの fireEvent 相当 API で pointerdown イベントを直接発火させ、ライブラリの「外側クリック検知」ロジックだけをテストするとよい。