C
React/API連携/Lesson 16

React Queryでサーバー状態を管理する

40分·theory
このチャプター
2/2
TypeScript

React Queryでサーバー状態を管理する

💡 なぜ学ぶべきか?

🎯 サーバーデータのキャッシングと同期を自動で処理し、コード量を70%削減します。
💼 バックグラウンドでの再検証により、常に最新データを保ちながらUXを向上させます。
エラー処理、リトライ、ローディング状態の管理が宣言的にシンプルになります。
🔗 大規模サービス(Karrot Market、Tossなど)で必須の技術です。
🏢 실무에서는
実務では、商品一覧を取得する際にReact Queryが自動でキャッシングを行い、ユーザーが別のページへ移動して戻ってきても、キャッシュされたデータを即座に表示できます。また、5分ごとにバックグラウンドで自動更新され、常に最新の価格が維持されます。

概念

React Queryは、サーバー状態のキャッシング、同期、再検証を自動化するライブラリです。fetchコードやローディング・エラー状態管理の繰り返しを劇的に削減できます。

なぜ重要なのか

サーバー状態管理の70%をReact Queryが自動処理します。キャッシング、バックグラウンド再検証、楽観的更新、無限スクロールを宣言的に実装できます。

コアコンセプト

React Query コアコンセプト

クエリ状態マシン

code
fetching (初回ローディング)
    ↓
fresh (新鮮、staleTime以内)
    ↓
stale (古い、再検証待ち)
    ↓
inactive (サブスクライバーなし、cacheTime後に削除)

主な設定

javascript
{
  staleTime: 5 * 60 * 1000,  // 5分: 新鮮だと見なす
  cacheTime: 10 * 60 * 1000, // 10分: 非アクティブなキャッシュを保持
  retry: 3,                   // 失敗時の再試行回数
  refetchOnWindowFocus: true, // タブアクティブ時に再検証
  refetchInterval: 30000,     // 30秒ごとにポーリング
}

キャッシュキー設計の原則

javascript
// 配列形式: 階層的な無効化が可能
['users']                    // すべてのユーザーリスト
['users', userId]            // 特定のユーザー
['users', userId, 'posts']  // 特定のユーザーの投稿

// invalidateQueries(['users'])ですべてのユーザー関連キャッシュを一括無効化

バックグラウンド再検証(stale-while-revalidate)

1. キャッシュされたデータを即座に返す(高速レスポンス)

2. バックグラウンドで新しいデータをフェッチ

3. 新しいデータが届いたらUIを更新

→ ユーザーは空白画面ではなく、直ちに前のデータを見られる

キーポイント

  • stale-while-revalidate: キャッシュを即時返却 + バックグラウンド再検証
  • クエリキー: 階層的な設計で関連キャッシュを一括無効化
  • useMutation + onMutate: 楽観的更新でUXを向上
  • useInfiniteQuery: 無限スクロールを宣言的に実装
💻 useQuery + useInfiniteQuery 実践
import {
  useQuery,
  useInfiniteQuery,
  useQueryClient
} from '@tanstack/react-query';
import { useIntersectionObserver } from '../hooks/useIntersectionObserver';

// === 1. 基本データ照会 ===
function LearningItem({ itemId }) {
  const {
    data,
    isLoading,
    isError,
    error,
    isFetching  // バックグラウンド再検証中
  } = useQuery({
    queryKey: ['learning-items', itemId],
    queryFn: async () => {
      const res = await fetch(`/api/items/${itemId}`);
      if (!res.ok) throw new Error('項目照会失敗');
      return res.json();
    },
    staleTime: 10 * 60 * 1000, // 10分間新鮮
    enabled: !!itemId,
  });

  if (isLoading) return <Skeleton />;
  if (isError) return <ErrorMessage message={error.message} />;

  return (
    <article>
      {isFetching && <span className="refreshing">更新中...</span>}
      <h1>{data.title}</h1>
      <p>{data.content}</p>
    </article>
  );
}

// === 2. 無限スクロール ===
function ItemList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading
  } = useInfiniteQuery({
    queryKey: ['items'],
    queryFn: ({ pageParam = 0 }) =>
      fetch(`/api/items?cursor=${pageParam}&limit=20`).then(r => r.json()),
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
  });

  // IntersectionObserverでスクロールの終わりを検出
  const [sentinelRef, isIntersecting] = useIntersectionObserver();

  React.useEffect(() => {
    if (isIntersecting && hasNextPage && !isFetchingNextPage) {
      fetchNextPage();
    }
  }, [isIntersecting, hasNextPage, isFetchingNextPage]);

  if (isLoading) return <Loading />;

  // pages: 各ページデータ配列
  const allItems = data.pages.flatMap(page => page.items);

  return (
    <div>
      {allItems.map(item => (
        <ItemCard key={item.id} item={item} />
      ))}
      {/* スクロール検出センチネル */}
      <div ref={sentinelRef}>
        {isFetchingNextPage && <Spinner />}
        {!hasNextPage && <p>すべての項目を読み込みました。</p>}
      </div>
    </div>
  );
}
💻 useMutation + キャッシュ無効化パターン
import { useMutation, useQueryClient } from '@tanstack/react-query';

// === 学習進捗の更新 + 楽観的更新 ===
function ProgressButton({ itemId, isCompleted }) {
  const queryClient = useQueryClient();

  const toggleMutation = useMutation({
    mutationFn: async (completed) => {
      const res = await fetch(`/api/progress/${itemId}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ completed })
      });
      if (!res.ok) throw new Error('進捗の更新に失敗しました');
      return res.json();
    },

    // 楽観的更新: 応答前にUIを即時変更
    onMutate: async (completed) => {
      // 進行中のクエリをキャンセル
      await queryClient.cancelQueries({ queryKey: ['progress', itemId] });

      // ロールバック用のスナップショット
      const previousProgress = queryClient.getQueryData(['progress', itemId]);

      // UIを即時更新
      queryClient.setQueryData(['progress', itemId], old => ({
        ...old,
        completed,
        completedAt: completed ? new Date().toISOString() : null
      }));

      return { previousProgress };
    },

    // エラー時にロールバック
    onError: (err, completed, context) => {
      queryClient.setQueryData(
        ['progress', itemId],
        context?.previousProgress
      );
      alert('更新失敗: ' + err.message);
    },

    // 成功/失敗後にサーバーデータで再同期
    onSettled: () => {
      // 関連するキャッシュをすべて無効化 (progress + 全体進捗統計)
      queryClient.invalidateQueries({ queryKey: ['progress', itemId] });
      queryClient.invalidateQueries({ queryKey: ['stats'] });
    }
  });

  return (
    <button
      onClick={() => toggleMutation.mutate(!isCompleted)}
      disabled={toggleMutation.isPending}
      className={isCompleted ? 'completed' : ''}
    >
      {toggleMutation.isPending ? '保存中...' : (isCompleted ? '完了 ✓' : '完了する')}
    </button>
  );
}

💡 ⚠️ よくある間違い

  • queryKeyを単一の文字列だけで使う — 配列形式で階層化しないと関連キャッシュの一括無効化ができない
  • staleTime/cacheTimeを設定しない — デフォルト値ではフォーカスのたびに再リクエストが発生する。データの特性に合わせて調整すること
  • useMutation後にinvalidateQueriesを忘れる — リストに新しい項目が反映されない。onSuccessで関連クエリの無効化を必ず行うこと
  • queryFn内でエラーをキャッチして処理する — React Queryがエラー状態として扱うにはthrowが必要。catchした後に再throwすること

💡 🎯 面接対策

Q: React QueryとReduxの違いは何ですか?
Q: staleTimeとcacheTimeの違いを説明してください
Q: 楽観的更新はどのように実装しますか?

ヒント: Reduxはクライアント状態全体を管理しますが、React Queryはサーバー状態に特化してキャッシング・再検証・同期を自動化します。staleTimeはデータを「新鮮」とみなす期間(再リクエストしない)で、cacheTimeは非アクティブなキャッシュをメモリに保持する期間です。楽観的更新は、onMutateでsetQueryDataを使ってUIを先に変更し、エラー時はonErrorで以前のデータにロールバック、onSettledでサーバーデータと再同期します。

⚛️ React パターン — React Queryでサーバー状態を管理する

React QueryによるサーバーState管理を、コード例とともにステップごとに学びましょう。
1 🧩 1. React Queryによるサーバー状態管理の使用シナリオ
この機能が必要な場面。
2 💻 2. コードを書く
React Queryによるサーバー状態管理の基本的な使い方。
3 🎨 3. レンダリング結果
ユーザーが見る画面。
4 💡 4. 実践的なヒント
よくある落とし穴とベストプラクティス。

🎮 React Queryでサーバー状態を管理する — ステップごとの理解

各ステップをクリックして内容を読み、✓ 理解しましたボタンで進捗を確認してください。
🖥️ 実行結果 — レンダリングされたReactコンポーネント
✏️ React 코드 수정하기 (클릭해서 열기)
⚛️ React 18 + Babel Standalone — まず結果を確認し、エディタで自由に編集できます。

確認クイズ

React Queryのコア機能ではないものはどれですか?
💡 React Queryはサーバー状態(APIデータ)の管理に特化しています。キャッシング、自動リフェッチ、ローディング/エラー状態を自動で処理します。グローバルなクライアント状態(UI状態など)の管理には、React QueryよりもZustandやContextの方が適しています。
React Query (TanStack Query) - React