ReactでTanStack Queryを使い、更新したあとに一覧を読み直す方法

​​React​TanStack Query,TypeScript

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' です。

tsx
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 だけを渡した書き方では、次のようになりました。[ ] の中は、サーバーの関数が呼ばれた回数です。出力は、牛乳の「完了にする」を押したところから抜き出しています(この記事の出力は、どれも同じように一部を抜き出したものです)。

text
$ 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 で呼ぶ形にしました。

ts
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 を書いていません。違いはここだけです。

ts
onSuccess: () => {
  queryClient.invalidateQueries({ queryKey: todoKeys.all });
},
text
$ 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 を返さない形(上)だけに「更新しました」と「完了にする」が出ています。

onSuccess で Promise を返さない形と返す形を縦に並べ、牛乳を完了にしてからサーバーの応答を2回返したときの表示の変化
完了にしてから応答を2回返す動き(上:Promise を返さない形、下:Promise を返す形)

表示していない一覧は、次に開いたときに読み直される

['todos'] で無効にしたとき、表示していない「完了」タブの一覧はどうなるかも確かめました。手順の最初で「完了」タブを一度開き、「すべて」タブに戻ってから牛乳を完了にしています。先ほどの Promise を返す形の、続きの出力です。

text
$ 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' を渡します。

ts
onSuccess: () => queryClient.invalidateQueries({ queryKey: todoKeys.all, refetchType: 'all' }),
text
$ node run.mjs useCompleteRefetchAll
useCompleteRefetchAll
  5. 更新の応答が届いた: 読み直し中… / 牛乳を買う [更新中…] / 卵を買う [更新中…] / ✓ パンを買う  [一覧(すべて) 2回, 一覧(完了) 2回, 更新 1回]
  6. 待っている応答をもう一度返した: ✓ 牛乳を買う / 卵を買う [完了にする] / ✓ パンを買う / 更新しました  [一覧(すべて) 2回, 一覧(完了) 2回, 更新 1回]
  7. 「完了」タブを開いた: ✓ 牛乳を買う / ✓ パンを買う  [一覧(すべて) 2回, 一覧(完了) 2回, 更新 1回]

更新の応答が届いた時点で、2つの一覧を両方とも読み直しました。そのため、「完了」タブを開いた瞬間から牛乳が入っています。その代わり、開くかどうか分からない一覧の分も、サーバーへの問い合わせが増えます。初期設定の 'active' のままでも、開いたときには正しい一覧になります。

応答の値でキャッシュを書き換えれば、読み直しを省ける

サーバーが更新後の値を返すなら、それでキャッシュを書き換える方法もあります。更新の応答でキャッシュを書き換える方法を説明したガイドでは、onSuccess で受け取った値を setQueryData に渡しています。同じガイドによると、キャッシュの中のデータを直接書き換えてはいけません。setQueryData に渡す関数では、新しい配列やオブジェクトを作って返します。

ts
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)),
      );
    },
  });
}
text
$ node run.mjs useCompleteSetData
useCompleteSetData
  5. 更新の応答が届いた: ✓ 牛乳を買う / 卵を買う [完了にする] / ✓ パンを買う / 更新しました  [一覧(すべて) 1回, 一覧(完了) 1回, 更新 1回]
  7. 「完了」タブを開いた: ✓ パンを買う  [一覧(すべて) 1回, 一覧(完了) 1回, 更新 1回]
  8. 待っている応答を返した: ✓ パンを買う  [一覧(すべて) 1回, 一覧(完了) 1回, 更新 1回]

更新の応答が届いた時点で、牛乳に ✓ が付きました。一覧の問い合わせは1回のままです。ところが、「完了」タブを開いても牛乳は出ませんでした。書き換えたのは「すべて」の一覧だけで、「完了」の一覧は古いままで、staleTime の間なので読み直されもしなかったためです。

そこで、書き換えていない「完了」の一覧だけを invalidateQueries で無効にしました。

ts
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') });
},
text
$ 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 で書き換え、残りを無効にする、という組み合わせもできます。

​Posted by 86