ReactでTanStack Queryを使い、更新したあとに一覧を読み直す方法
TanStack Query で一覧を表示している画面では、useMutation の onSuccess で invalidateQueries を呼ぶと、更新のあとに一覧を読み直せます。クエリキーの先頭をそろえておけば、条件の違う一覧もまとめて対象にできます。サーバーが更新後の値を返すなら、setQueryData でキャッシュを書き換え、読み直しを省くこともできます。
筆者は検証環境で、TODO の一覧の「完了にする」ボタンを押したあとの表示を、useMutation の書き方を6通りに変えて比べました。書き方によって、どの一覧がいつ読み直されるか、「更新中…」の表示がいつ終わるかが変わりました。
検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@tanstack/react-query 5.104.5、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、jsdom 29.1.1 です。.tsx を tsc で型チェックしてから esbuild で変換し、jsdom の上で描画しました。GIF の撮影には、Playwright 1.56.1 で操作する Chromium を使いました。サーバーは本物ではなく、呼ばれた回数を数え、こちらが合図するまで応答しない関数で代用しています。楽観的更新(応答の前に画面を書き換える方法)と、Suspense を使う useSuspenseQuery は試していません。
一覧のクエリキーは、先頭をそろえておく
検証に使った画面には、「すべて」と「完了」の2つのタブがあります。タブごとに別の一覧を読むので、クエリも2つに分けました。キーは ['todos', 'all'] と ['todos', 'done'] で、先頭はどちらも 'todos' です。
import { useQuery } from '@tanstack/react-query';
import { getTodos, type Filter } from './server';
// 一覧は filter ごとに別のクエリにする。キーの先頭はどちらも 'todos'。
export const todoKeys = {
all: ['todos'] as const,
list: (filter: Filter) => ['todos', filter] as const,
};
// TodoList の中
const { data, isFetching } = useQuery({
queryKey: todoKeys.list(filter),
queryFn: () => getTodos(filter),
});
一覧の各行には「完了にする」ボタンがあり、押すと useMutation の mutate を呼びます。サーバーの completeTodo は、更新したあとの1件を返します。
QueryClient の staleTime は60秒にしました。TanStack Query の重要な初期設定のガイドによると、staleTime の間は、データは新しい(fresh)ものとして扱われ、キャッシュから読まれます。そのため、更新のあとに何もしなければ、一覧は古いままです。検証でも、useMutation に mutationFn だけを渡した書き方では、次のようになりました。[ ] の中は、サーバーの関数が呼ばれた回数です。出力は、牛乳の「完了にする」を押したところから抜き出しています(この記事の出力は、どれも同じように一部を抜き出したものです)。
$ node run.mjs useCompleteNothing
useCompleteNothing
4. 牛乳を「完了にする」を押した: 牛乳を買う [更新中…] / 卵を買う [更新中…] / ✓ パンを買う [一覧(すべて) 1回, 一覧(完了) 1回, 更新 1回]
5. 更新の応答が届いた: 牛乳を買う [完了にする] / 卵を買う [完了にする] / ✓ パンを買う / 更新しました [一覧(すべて) 1回, 一覧(完了) 1回, 更新 1回]
6. 待っている応答をもう一度返した: 牛乳を買う [完了にする] / 卵を買う [完了にする] / ✓ パンを買う / 更新しました [一覧(すべて) 1回, 一覧(完了) 1回, 更新 1回]
「更新しました」と出ても、牛乳には ✓ が付かず、一覧は1回しか読まれていません。なお、一覧の全行のボタンが1つの useMutation を共有しているので、更新中は卵のボタンも「更新中…」になります。
onSuccess で invalidateQueries の Promise を返す
一覧を読み直すには、更新が成功したときに invalidateQueries を呼びます。更新のあとにクエリを無効にする方法を説明した TanStack Query のガイドと同じく、onSuccess で呼ぶ形にしました。
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { completeTodo } from './server';
import { todoKeys } from './TodoApp';
export function useCompleteInvalidate() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: completeTodo,
onSuccess: () => queryClient.invalidateQueries({ queryKey: todoKeys.all }),
});
}
todoKeys.all は ['todos'] です。クエリの無効化のガイドによると、['todos'] を渡すと、キーが todos で始まるクエリがすべて無効になります。無効になったクエリは、staleTime の設定にかかわらず古いものとして扱われ、そのとき表示しているものは裏で読み直されます。['todos', 'done'] のように具体的なキーを渡せば、その一覧だけを対象にできます。exact: true を付けると、キーが完全に同じクエリだけになります。
この書き方をもとに、onSuccess の中で invalidateQueries を呼ぶだけで Promise を返さない形と、Promise を返す形を比べました。Promise を返さない形の onSuccess は、次のように { } で囲み、return を書いていません。違いはここだけです。
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: todoKeys.all });
},
$ node run.mjs useCompleteNoReturn
useCompleteNoReturn
4. 牛乳を「完了にする」を押した: 牛乳を買う [更新中…] / 卵を買う [更新中…] / ✓ パンを買う [一覧(すべて) 1回, 一覧(完了) 1回, 更新 1回]
5. 更新の応答が届いた: 読み直し中… / 牛乳を買う [完了にする] / 卵を買う [完了にする] / ✓ パンを買う / 更新しました [一覧(すべて) 2回, 一覧(完了) 1回, 更新 1回]
6. 待っている応答をもう一度返した: ✓ 牛乳を買う / 卵を買う [完了にする] / ✓ パンを買う / 更新しました [一覧(すべて) 2回, 一覧(完了) 1回, 更新 1回]
$ node run.mjs useCompleteInvalidate
useCompleteInvalidate
4. 牛乳を「完了にする」を押した: 牛乳を買う [更新中…] / 卵を買う [更新中…] / ✓ パンを買う [一覧(すべて) 1回, 一覧(完了) 1回, 更新 1回]
5. 更新の応答が届いた: 読み直し中… / 牛乳を買う [更新中…] / 卵を買う [更新中…] / ✓ パンを買う [一覧(すべて) 2回, 一覧(完了) 1回, 更新 1回]
6. 待っている応答をもう一度返した: ✓ 牛乳を買う / 卵を買う [完了にする] / ✓ パンを買う / 更新しました [一覧(すべて) 2回, 一覧(完了) 1回, 更新 1回]
どちらも、更新の応答が届いた時点で「すべて」の一覧を読み直し始め、最後には牛乳に ✓ が付きました。
違いは、更新の応答が届いてから一覧の応答が届くまでの間です。Promise を返さない形では、isPending がすぐ false に戻りました。「更新しました」と出ているのに、牛乳はまだ ✓ のない古い行で、もう一度押せる「完了にする」ボタンも出ています。Promise を返す形では、一覧を読み直し終えるまで「更新中…」が続き、「更新しました」と ✓ が同時に出ました。更新のあとにクエリを無効にするガイドにも、onSuccess で Promise を返すと、データが新しくなるまで isPending が true のままになると書かれています。
onSuccess では invalidateQueries の Promise を返します。こうすると、isPending が一覧の読み直しまで含めた「更新中」を表すようになり、古い一覧と「更新しました」が同時に出ることがなくなります。
次の GIF は、Promise を返さない形(上)と返す形(下)を Chromium の画面に縦に並べ、同じ操作をした様子です。「両方で『牛乳を買う』を完了にする」ボタンは、上と下の牛乳の「完了にする」を順に押します。「サーバーの応答を返す」ボタンは、両方のサーバーで待っている応答を返します。上と下は、それぞれ別のサーバーと QueryClient を持っています。
押したあとに新しく出た表示は、ピンクで示しています。2回目のクリック(最初にサーバーの応答を返したとき)のあと、Promise を返さない形(上)だけに「更新しました」と「完了にする」が出ています。

表示していない一覧は、次に開いたときに読み直される
['todos'] で無効にしたとき、表示していない「完了」タブの一覧はどうなるかも確かめました。手順の最初で「完了」タブを一度開き、「すべて」タブに戻ってから牛乳を完了にしています。先ほどの Promise を返す形の、続きの出力です。
$ node run.mjs useCompleteInvalidate
useCompleteInvalidate
6. 待っている応答をもう一度返した: ✓ 牛乳を買う / 卵を買う [完了にする] / ✓ パンを買う / 更新しました [一覧(すべて) 2回, 一覧(完了) 1回, 更新 1回]
7. 「完了」タブを開いた: 読み直し中… / ✓ パンを買う [一覧(すべて) 2回, 一覧(完了) 2回, 更新 1回]
8. 待っている応答を返した: ✓ 牛乳を買う / ✓ パンを買う [一覧(すべて) 2回, 一覧(完了) 2回, 更新 1回]
更新の直後に読み直したのは「すべて」の一覧だけで、「完了」の一覧は1回のままでした。「完了」タブを開いたところで初めて読み直し、それまではキャッシュの古い一覧(パンだけ)を出しています。staleTime の60秒はまだ過ぎていません。キャッシュに何もしない書き方では、ここで「完了」タブを開いても読み直さず、パンだけの一覧が出たままでした。
インストールした TanStack Query 5.104.5 の queryClient.tsを読むと、invalidateQueries は、条件に合うクエリをすべて無効にしたあと、refetchType で指定した種類('active'・'inactive'・'all')のクエリだけを読み直す作りになっています('none' なら何も読み直しません)。refetchType も type も指定しなければ 'active'、つまり表示しているクエリだけです。表示していないクエリは、無効の印が付いたままキャッシュに残り、次に使われたときに読み直されます。
更新の直後に、表示していない一覧も読み直したいときは、refetchType: 'all' を渡します。
onSuccess: () => queryClient.invalidateQueries({ queryKey: todoKeys.all, refetchType: 'all' }),
$ node run.mjs useCompleteRefetchAll
useCompleteRefetchAll
5. 更新の応答が届いた: 読み直し中… / 牛乳を買う [更新中…] / 卵を買う [更新中…] / ✓ パンを買う [一覧(すべて) 2回, 一覧(完了) 2回, 更新 1回]
6. 待っている応答をもう一度返した: ✓ 牛乳を買う / 卵を買う [完了にする] / ✓ パンを買う / 更新しました [一覧(すべて) 2回, 一覧(完了) 2回, 更新 1回]
7. 「完了」タブを開いた: ✓ 牛乳を買う / ✓ パンを買う [一覧(すべて) 2回, 一覧(完了) 2回, 更新 1回]
更新の応答が届いた時点で、2つの一覧を両方とも読み直しました。そのため、「完了」タブを開いた瞬間から牛乳が入っています。その代わり、開くかどうか分からない一覧の分も、サーバーへの問い合わせが増えます。初期設定の 'active' のままでも、開いたときには正しい一覧になります。
応答の値でキャッシュを書き換えれば、読み直しを省ける
サーバーが更新後の値を返すなら、それでキャッシュを書き換える方法もあります。更新の応答でキャッシュを書き換える方法を説明したガイドでは、onSuccess で受け取った値を setQueryData に渡しています。同じガイドによると、キャッシュの中のデータを直接書き換えてはいけません。setQueryData に渡す関数では、新しい配列やオブジェクトを作って返します。
export function useCompleteSetData() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: completeTodo,
onSuccess: (updated) => {
queryClient.setQueryData<Todo[]>(todoKeys.list('all'), (old) =>
old?.map((t) => (t.id === updated.id ? updated : t)),
);
},
});
}
$ node run.mjs useCompleteSetData
useCompleteSetData
5. 更新の応答が届いた: ✓ 牛乳を買う / 卵を買う [完了にする] / ✓ パンを買う / 更新しました [一覧(すべて) 1回, 一覧(完了) 1回, 更新 1回]
7. 「完了」タブを開いた: ✓ パンを買う [一覧(すべて) 1回, 一覧(完了) 1回, 更新 1回]
8. 待っている応答を返した: ✓ パンを買う [一覧(すべて) 1回, 一覧(完了) 1回, 更新 1回]
更新の応答が届いた時点で、牛乳に ✓ が付きました。一覧の問い合わせは1回のままです。ところが、「完了」タブを開いても牛乳は出ませんでした。書き換えたのは「すべて」の一覧だけで、「完了」の一覧は古いままで、staleTime の間なので読み直されもしなかったためです。
そこで、書き換えていない「完了」の一覧だけを invalidateQueries で無効にしました。
onSuccess: (updated) => {
queryClient.setQueryData<Todo[]>(todoKeys.list('all'), (old) =>
old?.map((t) => (t.id === updated.id ? updated : t)),
);
return queryClient.invalidateQueries({ queryKey: todoKeys.list('done') });
},
$ node run.mjs useCompleteSetDataAndInvalidate
useCompleteSetDataAndInvalidate
5. 更新の応答が届いた: ✓ 牛乳を買う / 卵を買う [完了にする] / ✓ パンを買う / 更新しました [一覧(すべて) 1回, 一覧(完了) 1回, 更新 1回]
7. 「完了」タブを開いた: 読み直し中… / ✓ パンを買う [一覧(すべて) 1回, 一覧(完了) 2回, 更新 1回]
8. 待っている応答を返した: ✓ 牛乳を買う / ✓ パンを買う [一覧(すべて) 1回, 一覧(完了) 2回, 更新 1回]
「すべて」の一覧は読み直さずに ✓ が付き、「完了」の一覧は開いたときに読み直して牛乳が入りました。invalidateQueries が読み直すのは表示している一覧だけなので、ここでは読み直すものがありません。そのため Promise はすぐに終わり、「更新しました」も更新の応答と同時に出ています。
setQueryData を使うときは、更新した値が入るはずの一覧をすべて自分で書き換えるか、書き換えない一覧を無効にする必要があります。どの一覧に影響するかを書き分けるのが手間なら、先頭のキーで invalidateQueries を呼ぶ形のほうが漏れがありません。
重要な初期設定のガイドによると、staleTime に 'static' を指定したクエリは、invalidateQueries を呼んでも読み直されません。Infinity なら、invalidateQueries で無効にして読み直せます。この違いは、筆者は試していません。
書き方ごとの違い
ここまでの検証の結果をまとめます。「完了」の一覧は、更新のときには表示していない一覧です。
| 書き方 | 「すべて」の一覧(表示中) | 「完了」の一覧(非表示) | 「更新中…」が終わるとき |
|---|---|---|---|
| 何もしない | 古いまま | 古いまま | 更新の応答が届いたとき |
invalidateQueries を呼ぶだけ |
すぐ読み直す | 開いたときに読み直す | 更新の応答が届いたとき |
invalidateQueries の Promise を返す |
すぐ読み直す | 開いたときに読み直す | 一覧を読み直し終えたとき |
Promise を返す形に refetchType: 'all' を足す |
すぐ読み直す | すぐ読み直す | 一覧を読み直し終えたとき |
setQueryData だけ |
応答の値で書き換える | 古いまま | 更新の応答が届いたとき |
setQueryData と「完了」の無効化 |
応答の値で書き換える | 開いたときに読み直す | 更新の応答が届いたとき |
更新のあとに一覧を読み直す基本の形は、一覧のクエリキーの先頭をそろえ、useMutation の onSuccess で、その先頭のキーを渡した invalidateQueries の Promise を返すことです。表示している一覧はすぐに、表示していない一覧は次に開いたときに読み直されます。問い合わせを減らしたい一覧だけを setQueryData で書き換え、残りを無効にする、という組み合わせもできます。





