TanStack QueryとReact 19の新機能をどう使い分けるか

​​React​Suspense,TanStack Query,TypeScript,useActionState,useTransition

React 19 の use と Suspense は、データが届くまで「待つ表示」を受け持ちます。useActionState は、フォームの「送信中の状態」と結果を受け持ちます。一方で、取得したデータをどこにキャッシュし、いつ取り直すかは受け持ちません。そこを TanStack Query に任せ、待つ表示と送信の状態は React 19 の機能で書く、という分担ができます。

筆者は検証環境で、TODO の一覧を use と自前のキャッシュで読む版と、TanStack Query で読む版を作り、同じ手順で動かしました。違いが出たのは、画面を開き直したときと、フォームで1件追加したときです。

補足

検証環境は 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 を使いました。サーバーは本物ではなく、呼ばれた回数を数え、こちらが合図するまで応答しない関数で代用しています。

React 19 は、Promise のキャッシュを使う側に任せている

use のリファレンスにある、Client Component で Promise をキャッシュする方法の説明によると、Client Component で use に渡す Promise は、描画をやり直しても同じものを渡せるようにキャッシュしておく必要があります。キャッシュの方法は、Suspense と組み合わせるフレームワークによって変わります。フレームワークを使わない場合は、モジュールに置いた Map のような簡単なキャッシュでもよいとされています。

Suspense のリファレンスにある、Suspense に対応したフレームワークについての説明にも、こうしたフレームワークは内部で Promise のキャッシュを持ち、use で待つと書かれています。React の cache 関数は、cache のリファレンスによると Server Components 専用です。

そこで、まず use と自前の Map で一覧を読む版を作りました。件数を出すコンポーネントと一覧を出すコンポーネントが、同じデータを読みます。

tsx
import { Suspense, use } from 'react';
import { getTodos } from './server';

// React の use だけで読む。Promise は自分で Map にキャッシュする。
const cache = new Map<string, Promise<string[]>>();
function fetchTodos() {
  if (!cache.has('todos')) cache.set('todos', getTodos());
  return cache.get('todos')!;
}

// キャッシュを捨てる。次の描画で fetchTodos が新しく取得する。
export function clearTodos() {
  cache.delete('todos');
}

function TodoCount() {
  const todos = use(fetchTodos());
  return <p>{todos.length}件</p>;
}

clearTodos は、あとでフォームの検証に使います。一覧を出す TodoList も TodoCount と同じ形です。この2つを1つの Suspense で囲んだものを TodoPage として書き出し、あとのフォームの検証でも使います。TanStack Query の版は、use(fetchTodos()) を useSuspenseQuery に置き換えた形です。

tsx
import { Suspense } from 'react';
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query';
import { getTodos } from './server';

// TanStack Query で読む。キャッシュは QueryClient が持つ。
export const todosQuery = queryOptions({
  queryKey: ['todos'],
  queryFn: getTodos,
});

function TodoCount() {
  const { data } = useSuspenseQuery(todosQuery);
  return <p>{data.length}件</p>;
}

TanStack Query の Suspense のガイドによると、useSuspenseQuery は、読み込み中とエラーの表示を、React の Suspense とエラーバウンダリに任せます。そのため、data は必ず値の入った状態で返ります。こちらの版も、同じ形の TodoList と、2つを Suspense で囲んだ TodoPage を書き出しています。

閉じて開き直したとき、取り直すのは TanStack Query だけ

一覧を開いて応答を返したあと、サーバー側で1件増やしました。そのうえで一覧を閉じ、1.1秒待ってから開き直しました。別の画面に移って戻ってくる場面の代わりです。[ ] の中は、サーバーの関数が呼ばれた回数です。

text
$ node list.mjs ./out/ListUse.mjs
./out/ListUse.mjs 待つ時間 1100ms staleTime 指定なし
  1. 一覧を開いた: 閉じる / 読み込み中…  [getTodos 1回, addTodo 0回]
  2. 応答が届いた: 閉じる / 1件 / 牛乳を買う  [getTodos 1回, addTodo 0回]
  3. サーバーで1件増えたあと、一覧を閉じた: 開く  [getTodos 1回, addTodo 0回]
  4. もう一度開いた直後: 閉じる / 1件 / 牛乳を買う  [getTodos 1回, addTodo 0回]
  5. 待っている応答を返した: 閉じる / 1件 / 牛乳を買う  [getTodos 1回, addTodo 0回]

$ node list.mjs ./out/ListQuery.mjs
./out/ListQuery.mjs 待つ時間 1100ms staleTime 指定なし
  1. 一覧を開いた: 閉じる / 読み込み中…  [getTodos 1回, addTodo 0回]
  2. 応答が届いた: 閉じる / 1件 / 牛乳を買う  [getTodos 1回, addTodo 0回]
  3. サーバーで1件増えたあと、一覧を閉じた: 開く  [getTodos 1回, addTodo 0回]
  4. もう一度開いた直後: 閉じる / 1件 / 牛乳を買う  [getTodos 2回, addTodo 0回]
  5. 待っている応答を返した: 閉じる / 2件 / 牛乳を買う / 卵を買う  [getTodos 2回, addTodo 0回]

最初に開いたときは、どちらも getTodos の呼び出しは1回でした。2つのコンポーネントが同じデータを読んでも、取得は1回で済んでいます。

違いが出たのは、開き直したときです。自前の Map の版は、キャッシュした Promise をそのまま使い続けました。サーバーで増えた「卵を買う」は出ません。TanStack Query の版は、開き直した直後にキャッシュの「1件」を出し、裏で取り直しました。応答が届くと「2件」に変わりました。

TanStack Query の重要な初期設定のガイドによると、キャッシュしたデータは、初期設定ではすぐに古い(stale)ものとして扱われます。古いデータは、そのクエリを使うコンポーネントが新しく表示されたときなどに、裏で取り直されます。staleTime を長くすると、その間はキャッシュだけを使います。検証でも、staleTime を60秒にすると、開き直しても getTodos は1回のままでした。

text
$ node list.mjs ./out/ListQuery.mjs 1100 60000
./out/ListQuery.mjs 待つ時間 1100ms staleTime 60000
  1. 一覧を開いた: 閉じる / 読み込み中…  [getTodos 1回, addTodo 0回]
  2. 応答が届いた: 閉じる / 1件 / 牛乳を買う  [getTodos 1回, addTodo 0回]
  3. サーバーで1件増えたあと、一覧を閉じた: 開く  [getTodos 1回, addTodo 0回]
  4. もう一度開いた直後: 閉じる / 1件 / 牛乳を買う  [getTodos 1回, addTodo 0回]
  5. 待っている応答を返した: 閉じる / 1件 / 牛乳を買う  [getTodos 1回, addTodo 0回]

なお、閉じたあとの待ち時間を 0 ミリ秒にして開き直した場合は、staleTime を指定していないのに取り直しませんでした。TanStack Query のソースコードの suspense.ts では、Suspense を使うクエリの staleTime を最低 1000 ミリ秒に引き上げています。コメントによると、待ち(suspend)のあとでコンポーネントが表示し直されたときに、すぐ取り直さないようにするためです。検証で1.1秒待ったのは、このためです。

送信は useActionState、一覧の取り直しは invalidateQueries

次に、フォームで1件追加する場面を比べました。送信には、どちらの版も React 19 の useActionState を使います。useActionState のリファレンスによると、useActionState は、アクションの結果の state と、アクションが実行中かどうかを表す isPending を返します。TanStack Query の版では、送信のあとに invalidateQueries を呼んで、一覧のキャッシュを無効にしました。

tsx
import { useActionState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { addTodo } from './server';
import { TodoPage, todosQuery } from './ListQuery';

// 送信は useActionState。一覧は TanStack Query。送信のあとにキャッシュを無効にする。
export function AddPage() {
  const queryClient = useQueryClient();
  const [message, formAction, isPending] = useActionState(
    async (_prev: string, formData: FormData) => {
      await addTodo(String(formData.get('title')));
      await queryClient.invalidateQueries({ queryKey: todosQuery.queryKey });
      return '追加しました';
    },
    '',
  );

  return (
    <>
      <form action={formAction}>
        <input name="title" />
        <button disabled={isPending}>{isPending ? '追加中…' : '追加'}</button>
        {message && <span>{message}</span>}
      </form>
      <TodoPage />
    </>
  );
}

自前の Map の版は、同じ形で invalidateQueries の行がないコンポーネントです。clear を渡したときだけ、その位置で clearTodos() を呼んでキャッシュを消します。下の出力で、コマンドの末尾に clear があるのがキャッシュを消す版です。

画面には、フォームのコンポーネントの外に、件数だけを出す「ヘッダー」も置きました。ヘッダーも、一覧と同じデータを読みます。入力欄に「卵を買う」を入れて送信し、待っている応答を2回に分けて返しました。

text
$ node add.mjs ./out/AddUse.mjs
./out/AddUse.mjs 
  1. 一覧が届いた: ヘッダー:1件 / 追加 / 1件 / 牛乳を買う  [getTodos 1回, addTodo 0回]
  2. 「卵を買う」を送信した直後: ヘッダー:1件 / 追加中… / 1件 / 牛乳を買う  [getTodos 1回, addTodo 1回]
  3. addTodo の応答が届いた: ヘッダー:1件 / 追加 / 追加しました / 1件 / 牛乳を買う  [getTodos 1回, addTodo 1回]
  4. 待っている応答をもう一度返した: ヘッダー:1件 / 追加 / 追加しました / 1件 / 牛乳を買う  [getTodos 1回, addTodo 1回]

$ node add.mjs ./out/AddUse.mjs clear
./out/AddUse.mjs clear あり
  1. 一覧が届いた: ヘッダー:1件 / 追加 / 1件 / 牛乳を買う  [getTodos 1回, addTodo 0回]
  2. 「卵を買う」を送信した直後: ヘッダー:1件 / 追加中… / 1件 / 牛乳を買う  [getTodos 1回, addTodo 1回]
  3. addTodo の応答が届いた: ヘッダー:1件 / 追加中… / 1件 / 牛乳を買う  [getTodos 2回, addTodo 1回]
  4. 待っている応答をもう一度返した: ヘッダー:1件 / 追加 / 追加しました / 2件 / 牛乳を買う / 卵を買う  [getTodos 2回, addTodo 1回]

$ node add.mjs ./out/AddQuery.mjs
./out/AddQuery.mjs 
  1. 一覧が届いた: ヘッダー:1件 / 追加 / 1件 / 牛乳を買う  [getTodos 1回, addTodo 0回]
  2. 「卵を買う」を送信した直後: ヘッダー:1件 / 追加中… / 1件 / 牛乳を買う  [getTodos 1回, addTodo 1回]
  3. addTodo の応答が届いた: ヘッダー:1件 / 追加中… / 1件 / 牛乳を買う  [getTodos 2回, addTodo 1回]
  4. 待っている応答をもう一度返した: ヘッダー:2件 / 追加 / 追加しました / 2件 / 牛乳を買う / 卵を買う  [getTodos 2回, addTodo 1回]

キャッシュに何もしない1つ目の版は、「追加しました」と出ても、一覧は1件のままでした。

キャッシュを消した2つ目の版は、一覧が2件になりました。送信の結果でフォームのコンポーネントが描画し直され、その中の一覧が fetchTodos から新しい Promise を受け取ったためと考えられます。ところが、ヘッダーは「1件」のままでした。ヘッダーはフォームのコンポーネントの外にあり、送信の結果では描画し直されないため、新しいデータを読みに行かなかったと考えられます。同じ画面に、1件と2件が並んだことになります。

TanStack Query の版は、ヘッダーも一覧も2件になりました。TanStack Query のクエリの無効化のガイドによると、invalidateQueries で無効にしたクエリは古いものとして扱われ、そのとき表示しているコンポーネントがあれば裏で取り直されます。キャッシュの例を示したガイドにも、同じクエリキーを使うコンポーネントは、取り直したデータでそろって更新されると書かれています。

ポイント

どのコンポーネントが同じデータを使っているかを把握し、まとめて新しくするのは、TanStack Query の役目です。React 19 の useActionState は、送信中かどうか(isPending)と送信の結果を受け持ちます。

TanStack Query の版では、isPending は一覧の取り直しが終わるまで true のままでした。出力の3番目の段階で、addTodo の応答が届いても「追加中…」が続いています。アクションの関数の中で invalidateQueries を await しているため、取り直しが終わるまで送信が終わりません。「追加しました」と2件の一覧は、同時に出ました。

キャッシュを消す自前の版でも、3番目の段階で「追加中…」が続きました。こちらは、取り直しを await で待っているわけではありません。送信の結果を描画する途中で一覧が新しい Promise を待つため、その描画が終わるまで isPending が true のまま残る、と考えられます。

次の GIF は、キャッシュを消す自前の版を①、TanStack Query の版を②として、Chromium の画面に縦に並べて動かした様子です。「2つのフォームで送信する」ボタンは、①と②のフォームを順に送信します。「サーバーの応答を返す」ボタンは、両方のサーバーで待っている応答を返します。①と②は、それぞれ別のサーバーを持っています。

押したあとに新しく出た表示は、ピンクで示しています。3回目のクリックのあと、②のヘッダーは「2件」に変わりますが、①のヘッダーは「1件」のままです。

キャッシュを消す自前の版と TanStack Query の版を縦に並べ、フォームで「卵を買う」を送信してからサーバーの応答を2回返したときの表示の変化
フォームで送信し、サーバーの応答を2回返す動き(上:use と自前のキャッシュ、下:TanStack Query)

useMutation で送信しても、結果は同じ

送信を TanStack Query の useMutation で行う書き方も試しました。送信(ミューテーション)のあとにクエリを無効にする方法を説明した TanStack Query のガイドと同じく、onSuccess で invalidateQueries の Promise を返す形にしています。

tsx
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { addTodo } from './server';
import { TodoPage, todosQuery } from './ListQuery';

// 送信も TanStack Query の useMutation で行う。
export function AddPage() {
  const queryClient = useQueryClient();
  const mutation = useMutation({
    mutationFn: addTodo,
    onSuccess: () => queryClient.invalidateQueries({ queryKey: todosQuery.queryKey }),
  });

  return (
    <>
      <form action={(formData) => mutation.mutate(String(formData.get('title')))}>
        <input name="title" />
        <button disabled={mutation.isPending}>{mutation.isPending ? '追加中…' : '追加'}</button>
        {mutation.isSuccess && <span>追加しました</span>}
      </form>
      <TodoPage />
    </>
  );
}
text
$ node add.mjs ./out/AddMutation.mjs
./out/AddMutation.mjs 
  1. 一覧が届いた: ヘッダー:1件 / 追加 / 1件 / 牛乳を買う  [getTodos 1回, addTodo 0回]
  2. 「卵を買う」を送信した直後: ヘッダー:1件 / 追加中… / 1件 / 牛乳を買う  [getTodos 1回, addTodo 1回]
  3. addTodo の応答が届いた: ヘッダー:1件 / 追加中… / 1件 / 牛乳を買う  [getTodos 2回, addTodo 1回]
  4. 待っている応答をもう一度返した: ヘッダー:2件 / 追加 / 追加しました / 2件 / 牛乳を買う / 卵を買う  [getTodos 2回, addTodo 1回]

表示の移り変わりは、useActionState の版と同じでした。同じガイドには、onSuccess で Promise を返すと、データが新しくなるまで isPending が true のままになると書かれています。

送信中の状態を持つのが useActionState か useMutation かの違いで、一覧を新しくするのは、どちらも invalidateQueries です。この検証の範囲では、どちらを選んでも画面の動きは変わりませんでした。

queryKey を変える更新は startTransition で包む

最後に、表示する人を a さんから b さんに切り替える場面を試しました。useSuspenseQuery の queryKey に userId を入れ、「次の人」ボタンで userId を変えます。

text
$ node user.mjs
setUserId をそのまま呼ぶ
  1. a さんを表示: 次の人 / a さんのTODO 1件  [getTodos 1回, addTodo 0回]
  2. 「次の人」を押した直後: 次の人 / 読み込み中…  [getTodos 2回, addTodo 0回]
  3. b さんの応答が届いた: 次の人 / b さんのTODO 1件  [getTodos 2回, addTodo 0回]

$ node user.mjs transition
startTransition で包む
  1. a さんを表示: 次の人 / a さんのTODO 1件  [getTodos 1回, addTodo 0回]
  2. 「次の人」を押した直後: 次の人 / 切り替え中… / a さんのTODO 1件  [getTodos 2回, addTodo 0回]
  3. b さんの応答が届いた: 次の人 / b さんのTODO 1件  [getTodos 2回, addTodo 0回]

そのまま setUserId を呼ぶと、a さんの表示が消えて「読み込み中…」になりました。startTransition で包むと、b さんのデータが届くまで a さんの表示が残り、isPending で「切り替え中…」を出せました。useTransition が返す isPending と startTransition についての説明は、useTransition のリファレンスにあります。

先ほどの TanStack Query の Suspense のガイドによると、useSuspenseQuery では、読み込み中に仮のデータを出す placeholderData が使えません。代わりに、queryKey を変える更新を startTransition で包むよう書かれています。前の画面を残して待つのは、ここでも React の役目です。

使い分けの早見表

ここまでの検証をもとに、それぞれの役目をまとめます。

やりたいこと 受け持つもの
データが届くまで「読み込み中」を出す React の Suspense(useSuspenseQuery もこれを使う)
同じデータを複数のコンポーネントで共有する TanStack Query のキャッシュ
画面を開き直したときに取り直す、staleTime の間は取り直さない TanStack Query
送信中の表示と、送信の結果 React の useActionState(useMutation でもよい)
送信のあと、関係する一覧をすべて新しくする TanStack Query の invalidateQueries
切り替えの間、前の画面を残して待つ React の startTransition

自前の Map でも、送信したコンポーネントの中だけなら、キャッシュを消して読み直す形で一覧を新しくできました。ただし、同じデータを読む別の場所まで新しくするには、仕組みを自分で作る必要があります。そうした場所が増えてきたら、キャッシュを TanStack Query に任せ、待つ表示と送信の状態は React 19 の機能で書く、という分け方ができます。

​Posted by 86