ReactにおけるTanStack Query v5完全攻略:サーバー状態管理・キャッシュ・実践的Optimistic Updates

Development tutorial - IT technology blog
Development tutorial - IT technology blog

API呼び出しにおける「useEffectの苦悩」

TanStack Query(旧React Query)へ移行する前、私のチームにおけるデータ取得フローはお馴染みの定番パターンでした。useStateでdata、isLoading、errorを管理し、API呼び出しをuseEffectでラップするというものです。プロジェクトが基本的なCRUDを数画面持つ程度であれば、この手法でも問題なく動作していました。しかし、システムが40画面を超えたあたりから、次々と厄介な問題に直面することになりました。

  • 重複リクエストの発生:ヘッダーとサイドバーの2つのウィジェットが同時に通知リストを必要とした場合、わずか100msの間にブラウザからバックエンドへ全く同一のリクエストが2重に送信されてしまいます。
  • データの不整合(Stale Data):ユーザーがプロフィール画面で表示名を変更した後、ダッシュボード画面に戻ると古い名前のまま残ってしまいます。ダッシュボードのコンポーネントがアンマウントされていないため、新しいデータを再取得するステートが自動発火しないことが原因です。
  • ボイラープレートコードの肥大化:AbortController、isMountedフラグ、リトライ処理、try/catchブロックなどを毎回書く必要があり、50行ほどのUIコンポーネントに対して100行以上のネットワーク処理コードが上乗せされてしまっていました。

多くのチームは、APIからのデータをすべてRedux ToolkitやZustandに詰め込むことでこの問題を解決しようとします。しかし、TanStack Query v5を本番環境に導入して半年が経った今、それは自ら状況を複雑にしていただけだと痛感しています。UIの状態とサーバーのデータは本質的に異なるものであり、それらを混同して一括管理することはコードベースをより混沌とさせるだけでした。

押さえておくべき3つのコア概念

ドキュメントにある無数のオプションを丸暗記するのではなく、以下の3つの基本的な考え方をしっかりと理解しておくことが重要です。

1. Client StateとServer Stateの明確な境界

状態の種類を見誤ると、アプリケーションの設計が最初から間違った方向へ進んでしまいます。

  • Client State(クライアント状態):純粋にUIに属するデータであり、常に同期的でブラウザのメモリ上に完結します。例えば、モーダルの開閉、アクティブなタブ、ダークモードの切り替えなどです。この種の状態には、useStateや軽量なZustandストアで十分対応できます。
  • Server State(サーバー状態):バックエンドのデータベースに帰属する実質的なデータです。非同期であり、ブラウザ側が把握していない間に他のユーザーによって変更される可能性があります。このデータにはキャッシュ、再検証(revalidation)、継続的なリトライ機構が求められます。これこそがTanStack Queryの真骨頂です。

2. staleTimeとgcTimeの違いとは?

ここは最も誤解されやすく、APIへのスパムリクエストやメモリリークを引き起こす主な原因となります。

  • staleTime(デフォルト:0):データが「新鮮(fresh)」とみなされる期間です。データがfreshである限り、TanStack Queryはサーバーへアクセスせず、メモリから直接データを読み込みます。デフォルトのstaleTime: 0では、取得した直後のデータであっても即座に「古い(stale)」と判定されます。そのため、ユーザーがタブを切り替えて戻ってくるたびに、最新データとの同期のためにバックグラウンドでAPI呼び出しが発生します。
  • gcTime(Garbage Collection Time – v5でcacheTimeから改名、デフォルト:5分):使用されていない(inactiveな)データがメモリ内に保持される期間です。画面上のどのコンポーネントもそのクエリキーを購読しなくなると、gcTimeのカウントダウンが始まります。指定時間が経過すると、ガベージコレクターがメモリを解放するためにクリーンアップを実行します。

3. Query Keyの階層的な標準化

v5以降、Query Keyは配列(Array)であることが必須となりました。ライブラリは配列を深くハッシュ化することでキャッシュを一意に識別します。大分類から詳細へ向かう順序で、ファクトリオブジェクトとしてキーをまとめましょう。

// Query Key Factory:整理しやすく一括無効化(invalidate)が容易
export const todoKeys = {
  all: ['todos'] as const,
  lists: () => [...todoKeys.all, 'list'] as const,
  list: (filters: { status?: string }) => [...todoKeys.lists(), filters] as const,
  details: () => [...todoKeys.all, 'detail'] as const,
  detail: (id: string) => [...todoKeys.details(), id] as const,
};

TanStack Query v5 実践ステップガイド

ここからは、基盤となるセットアップから、v5構文によるデータクエリ、そしてOptimistic Updates(楽観的更新)の実装までを順を追って解説します。

ステップ1:QueryClientのインストールと標準的なセットアップ

コアパッケージとDevToolsをインストールします:

npm install @tanstack/react-query @tanstack/react-query-devtools

ルートファイル(App.tsxやmain.tsxなど)でQueryClientを初期化します。バックエンドに過度な負荷をかけないよう、staleTimeをデフォルトの0のままにしておくのは避けましょう。

import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import TodoApp from './TodoApp';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 2, // 2分間はデータを「fresh」として保持
      gcTime: 1000 * 60 * 10,    // 非アクティブなキャッシュをメモリ上に最大10分間保持
      retry: 1,                 // APIエラー時に1回だけリトライして失敗と判定
      refetchOnWindowFocus: false, // ウィンドウフォーカス時の自動再取得を無効化
    },
  },
});

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <TodoApp />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

ステップ2:useQueryによるデータ取得

TanStack Query v5では、複数の引数を渡す古い記法が完全に廃止され、1つの設定オブジェクトのみを受け取る形式に統一されました。また、まだデータが存在しない初期ロード状態を表すフラグとして、従来のisLoadingに代わりisPendingが導入されています。

import { useQuery } from '@tanstack/react-query';
import { todoKeys } from './queryKeys';

interface Todo {
  id: string;
  title: string;
  completed: boolean;
}

const fetchTodos = async (): Promise<Todo[]> => {
  const res = await fetch('/api/todos');
  if (!res.ok) throw new Error('タスク一覧の取得に失敗しました');
  return res.json();
};

export function TodoList() {
  const { data: todos, isPending, isError, error } = useQuery({
    queryKey: todoKeys.lists(),
    queryFn: fetchTodos,
  });

  if (isPending) return <p>データを同期中...</p>;
  if (isError) return <p className="error">エラー: {error.message}</p>;

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>
          <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
            {todo.title}
          </span>
        </li>
      ))}
    </ul>
  );
}

ステップ3:useMutationによるデータ変更と自動再取得(Auto Refetch)

POST、PUT、DELETEなどの更新処理にはuseMutationを使用します。ミューテーションの完了後にinvalidateQueriesを呼び出すだけで、TanStack Queryが現在表示中のコンポーネントに対して自動的に最新データを再取得してくれます。

私たちのチームが手がけた最近のEコマースプロジェクトでも、この仕組みを導入したことで、親コンポーネントから子コンポーネントへコールバックをバケツリレーする煩雑なロジックや、大量の冗長なReduxアクションを完全に削減できました。

import React, { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { todoKeys } from './queryKeys';

const createTodo = async (title: string) => {
  const res = await fetch('/api/todos', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ title, completed: false }),
  });
  if (!res.ok) throw new Error('Todoの作成に失敗しました');
  return res.json();
};

export function AddTodo() {
  const [title, setTitle] = useState('');
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: createTodo,
    onSuccess: () => {
      // Todoリストのキャッシュを古い(stale)とマークし、バックグラウンドでの再取得を即座にトリガー
      queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
      setTitle('');
    },
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (title.trim()) mutation.mutate(title.trim());
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="タスクを追加..."
      />
      <button type="submit" disabled={mutation.isPending}>
        {mutation.isPending ? '保存中...' : '追加'}
      </button>
    </form>
  );
}

ステップ4:Optimistic Updates(楽観的更新)でレイテンシのない軽快なUIを実現

通信が不安定でpingが400msある環境では、ユーザーがタスクの完了チェックをクリックしてからチェックボックスに反映されるまでにコンマ数秒のラグが生じます。これはユーザー体験を著しく損ないます。

Optimistic Update(楽観的更新)の手法は、この課題を根本から解決します。クリックした瞬間にUIの状態を即座に変更し、サーバーとの通信はバックグラウンドで処理します。万が一500エラーなどで失敗した場合には、UIを自動的に元の状態へとロールバックします。

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { todoKeys } from './queryKeys';

interface TogglePayload {
  id: string;
  completed: boolean;
}

const patchTodo = async ({ id, completed }: TogglePayload) => {
  const res = await fetch(`/api/todos/${id}`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ completed }),
  });
  if (!res.ok) throw new Error('ステータスの更新に失敗しました');
  return res.json();
};

export function useToggleTodo() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: patchTodo,
    onMutate: async (updatedTodo) => {
      // 競合状態(race condition)を防ぐため、進行中のクエリリクエストをキャンセル
      await queryClient.cancelQueries({ queryKey: todoKeys.lists() });

      // エラー時のロールバックに備えて以前のキャッシュをバックアップ
      const previousTodos = queryClient.getQueryData<Todo[]>(todoKeys.lists());

      // キャッシュを新しいデータで即座に更新
      queryClient.setQueryData<Todo[]>(todoKeys.lists(), (old = []) =>
        old.map((todo) =>
          todo.id === updatedTodo.id ? { ...todo, completed: updatedTodo.completed } : todo
        )
      );

      // コンテキストにスナップショットを返す
      return { previousTodos };
    },
    onError: (_err, _variables, context) => {
      // APIエラー発生時、バックアップデータへロールバック
      if (context?.previousTodos) {
        queryClient.setQueryData(todoKeys.lists(), context.previousTodos);
      }
    },
    onSettled: () => {
      // 成功・失敗を問わず、最終的にはバックエンドの正確なデータと同期
      queryClient.invalidateQueries({ queryKey: todoKeys.lists() });
    },
  });
}

実践から得た教訓とベストプラクティス

システム全体をTanStack Query v5へとリファクタリングした結果、私たちのチームでは状態管理コードを約60%削減できました。また、画面間でのデータ不整合は完全に解消されました。現場で役立つ3つの実践的なアドバイスをまとめます。

  • staleTimeをデフォルトの0のまま放置しない:商品カテゴリーやプロフィール情報のように変更頻度の低いデータについては、staleTimeを1〜5分程度に設定しましょう。不要なリクエストが毎日何千回も送られるのを防ぎ、バックエンドチームの負荷を大幅に軽減できます。
  • 開発環境ではDevToolsを常時有効にする:この視覚的なツールを使えば、どのキーがFresh、Stale、Inactiveであるかを瞬時に把握でき、どのコンポーネントが何回再レンダリングされているかも正確に追跡できます。
  • 必ずカスタムフックとしてカプセル化する:useQueryをUIコンポーネントに直接記述するのではなく、useTodos()やuseCreateTodo()のような専用のカスタムフックにラップすることで、ロジックの再利用性と見通しの良さを向上させましょう。

無秩序なuseEffectの迷宮に悩まされている方や、スピナーを1つ表示するためだけにReduxアクションをディスパッチすることに疲弊している方は、ぜひ今すぐTanStack Query v5を導入してみてください。サーバー状態の管理が驚くほどシンプルになるはずです。

Share: