T-CREATOR

React でデータ取得を最適化:TanStack Query 基礎からキャッシュ戦略まで実装

React でデータ取得を最適化:TanStack Query 基礎からキャッシュ戦略まで実装

React アプリケーションでデータ取得を行う際、useEffect と useState の組み合わせでローディング状態やエラーハンドリングを実装するのは大変ですよね。TanStack Query(旧 React Query)を使えば、これらの煩雑な処理をシンプルに記述できます。本記事では、TanStack Query の基礎から、実務で役立つキャッシュ戦略までを段階的に解説していきます。

背景

React でアプリケーションを開発する際、API からデータを取得する処理は避けて通れません。従来の方法では、useEffect フックを使ってコンポーネントのマウント時にデータを取得し、useState でローディング状態やエラー状態を管理する必要がありました。

typescript// 従来の実装例
import { useEffect, useState } from 'react';

interface User {
  id: number;
  name: string;
  email: string;
}
typescript// データ取得処理
function UserList() {
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    // データ取得を開始
    setLoading(true);
    fetch('/api/users')
      .then((res) => res.json())
      .then((data) => {
        setUsers(data);
        setLoading(false);
      })
      .catch((err) => {
        setError(err);
        setLoading(false);
      });
  }, []); // 空の依存配列でマウント時のみ実行

  // 以下、レンダリング処理...
}

上記のコードには以下のような問題があります。

#課題説明
1ボイラープレートの多さローディング、エラー、データの 3 つの状態を毎回管理する必要がある
2キャッシュ機能の欠如同じデータを複数のコンポーネントで使う際、毎回 API を呼び出してしまう
3データの更新管理の複雑さデータを更新した後、関連する全てのコンポーネントに反映させるのが困難
4再取得のタイミング制御ウィンドウフォーカス時やネットワーク再接続時の自動再取得ができない

以下の図は、従来の方法での課題を示しています。

mermaidflowchart TB
  componentA["コンポーネント A"] -->|API 呼び出し| apiCall1["GET /api/users"]
  componentB["コンポーネント B"] -->|API 呼び出し| apiCall2["GET /api/users"]
  componentC["コンポーネント C"] -->|API 呼び出し| apiCall3["GET /api/users"]

  apiCall1 --> server["API サーバー"]
  apiCall2 --> server
  apiCall3 --> server

  server -->|同じデータ| apiCall1
  server -->|同じデータ| apiCall2
  server -->|同じデータ| apiCall3

  style server fill:#f96,stroke:#333
  style componentA fill:#9cf,stroke:#333
  style componentB fill:#9cf,stroke:#333
  style componentC fill:#9cf,stroke:#333

このように、各コンポーネントが独立してデータを取得するため、同じデータに対して複数回 API リクエストが発生してしまいます。これはサーバーへの負荷を増やし、ユーザー体験も低下させる原因となるでしょう。

課題

React でのデータ取得において、開発者が直面する主な課題は以下の通りです。

状態管理の煩雑さ

データ取得には必ず「取得中」「成功」「失敗」という 3 つの状態が存在します。これらを毎回 useState で管理するのは非効率的です。

typescript// 3つの状態を管理する必要がある
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);

キャッシュの実装難易度

同じデータを複数箇所で使う場合、自前でキャッシュ機構を実装するのは複雑です。キャッシュの有効期限や更新タイミングの制御も考慮する必要があります。

データの同期問題

あるコンポーネントでデータを更新した際、他のコンポーネントにも反映させる仕組みが必要になります。グローバルステートを使う方法もありますが、設計が複雑化しがちです。

以下の図は、データ同期の課題を表しています。

mermaidstateDiagram-v2
  [*] --> DataFetch: ユーザー操作
  DataFetch --> Loading: API リクエスト
  Loading --> Success: レスポンス成功
  Loading --> ErrorState: レスポンス失敗

  Success --> Stale: 時間経過
  Stale --> DataFetch: 再取得

  ErrorState --> Retry: リトライ
  Retry --> Loading

  Success --> [*]
  ErrorState --> [*]

この状態管理を全て手動で実装するのは大変な作業です。特に、データが古くなったタイミングを判断して再取得する処理は、実装が複雑になりやすいでしょう。

パフォーマンスの最適化

不要な API 呼び出しを減らし、ユーザー体験を向上させるには、以下のような機能が必要です。

#機能説明
1デデュープ(重複排除)同時に同じリクエストが発生した場合、1 回だけ実行する
2バックグラウンド更新古いデータを表示しながら、裏で新しいデータを取得する
3楽観的更新API レスポンスを待たずに UI を先に更新する
4自動リトライエラー時に自動的に再試行する

これらの機能を自前で実装するのは非常に困難です。そこで TanStack Query の出番となります。

解決策

TanStack Query は、React アプリケーションにおけるサーバーステート管理を劇的に簡素化するライブラリです。データの取得、キャッシュ、同期、更新を自動的に処理してくれます。

TanStack Query の導入

まずはパッケージをインストールします。

bashyarn add @tanstack/react-query

次に、アプリケーションのルートに QueryClient を設定します。

typescript// QueryClient のインポート
import {
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query';
typescript// QueryClient のインスタンスを作成
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // デフォルトのキャッシュ時間を5分に設定
      staleTime: 5 * 60 * 1000,
      // エラー時に3回まで自動リトライ
      retry: 3,
    },
  },
});
typescript// アプリケーション全体をプロバイダーでラップ
function App() {
  return (
    <QueryClientProvider client={queryClient}>
      {/* アプリケーションのコンポーネント */}
      <UserList />
    </QueryClientProvider>
  );
}

QueryClientProvider でアプリケーションをラップすることで、配下の全てのコンポーネントで TanStack Query の機能を使えるようになります。

基本的なデータ取得

TanStack Query を使った基本的なデータ取得の実装を見ていきましょう。

typescript// useQuery フックをインポート
import { useQuery } from '@tanstack/react-query';
typescript// データ取得関数を定義
const fetchUsers = async (): Promise<User[]> => {
  const response = await fetch('/api/users');

  // レスポンスのエラーチェック
  if (!response.ok) {
    throw new Error('ユーザー情報の取得に失敗しました');
  }

  return response.json();
};
typescript// コンポーネント内で useQuery を使用
function UserList() {
  const { data, isLoading, error } = useQuery({
    // 一意のクエリキー
    queryKey: ['users'],
    // データ取得関数
    queryFn: fetchUsers,
  });

  // ローディング状態の表示
  if (isLoading) return <div>読み込み中...</div>;

  // エラー状態の表示
  if (error) return <div>エラー: {error.message}</div>;

  // データの表示
  return (
    <ul>
      {data?.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

従来の方法と比較すると、コード量が大幅に削減されていることがわかります。ローディング状態やエラーハンドリングが自動的に管理されるため、開発者はビジネスロジックに集中できるでしょう。

以下の図は、TanStack Query を使った場合のデータフローを示しています。

mermaidflowchart LR
  compA["コンポーネント A"] -->|クエリ| cache["Query Cache"]
  compB["コンポーネント B"] -->|クエリ| cache
  compC["コンポーネント C"] -->|クエリ| cache

  cache -->|キャッシュミス| fetch["fetch 関数"]
  fetch -->|API 呼び出し| server["API サーバー"]
  server -->|レスポンス| fetch
  fetch -->|データ保存| cache

  cache -->|キャッシュヒット| compA
  cache -->|キャッシュヒット| compB
  cache -->|キャッシュヒット| compC

  style cache fill:#9f9,stroke:#333
  style server fill:#f96,stroke:#333

複数のコンポーネントが同じ queryKey でデータを要求しても、実際の API 呼び出しは 1 回だけ行われます。これにより、サーバーへの負荷が大幅に削減されるのです。

データの更新(Mutation)

データの作成、更新、削除には useMutation フックを使用します。

typescript// useMutation フックをインポート
import {
  useMutation,
  useQueryClient,
} from '@tanstack/react-query';
typescript// ユーザー作成関数を定義
const createUser = async (
  newUser: Omit<User, 'id'>
): Promise<User> => {
  const response = await fetch('/api/users', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(newUser),
  });

  if (!response.ok) {
    throw new Error('ユーザーの作成に失敗しました');
  }

  return response.json();
};
typescript// コンポーネント内で useMutation を使用
function CreateUserForm() {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    // データ更新関数
    mutationFn: createUser,
    // 成功時の処理
    onSuccess: () => {
      // ユーザーリストのキャッシュを無効化して再取得
      queryClient.invalidateQueries({
        queryKey: ['users'],
      });
    },
  });

  const handleSubmit = (
    e: React.FormEvent<HTMLFormElement>
  ) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);

    // mutation を実行
    mutation.mutate({
      name: formData.get('name') as string,
      email: formData.get('email') as string,
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name='name' placeholder='名前' required />
      <input
        name='email'
        type='email'
        placeholder='メール'
        required
      />
      <button type='submit' disabled={mutation.isPending}>
        {mutation.isPending
          ? '作成中...'
          : 'ユーザーを作成'}
      </button>
      {mutation.isError && (
        <div>エラー: {mutation.error.message}</div>
      )}
    </form>
  );
}

onSuccess コールバック内で invalidateQueries を呼び出すことで、関連するクエリのキャッシュを無効化し、自動的に最新のデータを再取得できます。これにより、データの一貫性が保たれるのです。

具体例

実際のプロジェクトで使える、より実践的な実装例を見ていきましょう。

キャッシュ戦略の設定

TanStack Query では、クエリごとに詳細なキャッシュ戦略を設定できます。

typescript// 個別のクエリ設定例
function UserDetail({ userId }: { userId: number }) {
  const { data, isLoading } = useQuery({
    queryKey: ['user', userId], // ユーザーIDを含めた一意のキー
    queryFn: () => fetchUser(userId),
    // キャッシュの設定
    staleTime: 10 * 60 * 1000, // 10分間はキャッシュを新鮮と見なす
    gcTime: 30 * 60 * 1000, // 30分間はメモリに保持(旧 cacheTime)
    refetchOnWindowFocus: true, // ウィンドウフォーカス時に再取得
    refetchOnReconnect: true, // ネットワーク再接続時に再取得
  });

  if (isLoading) return <div>読み込み中...</div>;

  return (
    <div>
      <h2>{data?.name}</h2>
      <p>{data?.email}</p>
    </div>
  );
}

各設定項目の意味を表にまとめます。

#オプション説明推奨値
1staleTimeデータが新鮮と見なされる時間頻繁に変更されるデータ: 0〜1 分あまり変わらないデータ: 5〜10 分
2gcTimeメモリに保持する時間staleTime の 2〜3 倍
3refetchOnWindowFocusウィンドウフォーカス時の再取得ユーザー関連データ: true静的データ: false
4refetchOnReconnectネットワーク再接続時の再取得通常は true

これらの設定を適切に行うことで、パフォーマンスとデータの鮮度のバランスを取れます。

楽観的更新の実装

楽観的更新を使うと、API レスポンスを待たずに UI を即座に更新できます。これによりユーザー体験が大幅に向上するでしょう。

typescript// 楽観的更新の型定義
interface UpdateUserParams {
  userId: number;
  updates: Partial<User>;
}
typescript// ユーザー更新の mutation 実装
function useUpdateUser() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({ userId, updates }: UpdateUserParams) =>
      fetch(`/api/users/${userId}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(updates),
      }).then((res) => res.json()),

    // mutation 実行前の処理
    onMutate: async ({ userId, updates }) => {
      // 進行中のクエリをキャンセル
      await queryClient.cancelQueries({
        queryKey: ['user', userId],
      });

      // 現在のデータを取得(ロールバック用)
      const previousUser = queryClient.getQueryData([
        'user',
        userId,
      ]);

      // 楽観的にデータを更新
      queryClient.setQueryData(
        ['user', userId],
        (old: User | undefined) => {
          if (!old) return old;
          return { ...old, ...updates };
        }
      );

      // ロールバック用のデータを返す
      return { previousUser };
    },

    // エラー時のロールバック
    onError: (err, variables, context) => {
      // 元のデータに戻す
      if (context?.previousUser) {
        queryClient.setQueryData(
          ['user', variables.userId],
          context.previousUser
        );
      }
    },

    // 成功時もエラー時も実行される処理
    onSettled: (data, error, variables) => {
      // クエリを再取得して最新の状態にする
      queryClient.invalidateQueries({
        queryKey: ['user', variables.userId],
      });
    },
  });
}
typescript// 楽観的更新の使用例
function UserEditForm({ user }: { user: User }) {
  const updateUser = useUpdateUser();

  const handleNameChange = (newName: string) => {
    // UI は即座に更新される
    updateUser.mutate({
      userId: user.id,
      updates: { name: newName },
    });
  };

  return (
    <input
      value={user.name}
      onChange={(e) => handleNameChange(e.target.value)}
      disabled={updateUser.isPending}
    />
  );
}

楽観的更新のフローを図で確認しましょう。

mermaidsequenceDiagram
  participant UI as UI コンポーネント
  participant Cache as Query Cache
  participant API as API サーバー

  UI->>Cache: 1. 現在のデータを保存
  UI->>Cache: 2. 楽観的にデータを更新
  Cache->>UI: 3. 新しいデータを即座に反映

  UI->>API: 4. API リクエスト送信

  alt 成功時
    API->>UI: 5a. レスポンス成功
    UI->>Cache: 6a. 最新データで再取得
  else エラー時
    API->>UI: 5b. エラーレスポンス
    UI->>Cache: 6b. 元のデータにロールバック
    UI->>UI: 7b. エラーメッセージ表示
  end

このように、楽観的更新では UI を先に更新し、API がエラーを返した場合のみロールバックします。成功率の高い操作では、ユーザーは待ち時間を感じることなく操作を続けられるのです。

ページネーションの実装

大量のデータを扱う場合、ページネーションは必須です。TanStack Query でのページネーション実装を見ていきます。

typescript// ページネーション用の型定義
interface PaginatedResponse<T> {
  data: T[];
  totalCount: number;
  pageCount: number;
  currentPage: number;
}
typescript// ページネーション付きデータ取得関数
const fetchUsers = async (
  page: number
): Promise<PaginatedResponse<User>> => {
  const response = await fetch(
    `/api/users?page=${page}&limit=10`
  );
  if (!response.ok)
    throw new Error('データの取得に失敗しました');
  return response.json();
};
typescript// ページネーション対応コンポーネント
function PaginatedUserList() {
  const [page, setPage] = useState(1);

  const { data, isLoading, isPlaceholderData } = useQuery({
    queryKey: ['users', page],
    queryFn: () => fetchUsers(page),
    // 前のページのデータを表示し続ける
    placeholderData: (previousData) => previousData,
  });

  // 次のページを先読み
  const queryClient = useQueryClient();
  useEffect(() => {
    if (data && page < data.pageCount) {
      queryClient.prefetchQuery({
        queryKey: ['users', page + 1],
        queryFn: () => fetchUsers(page + 1),
      });
    }
  }, [data, page, queryClient]);

  if (isLoading) return <div>読み込み中...</div>;

  return (
    <div>
      <ul style={{ opacity: isPlaceholderData ? 0.5 : 1 }}>
        {data?.data.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>

      <div>
        <button
          onClick={() => setPage((p) => Math.max(1, p - 1))}
          disabled={page === 1}
        >
          前へ
        </button>
        <span>
          ページ {page} / {data?.pageCount}
        </span>
        <button
          onClick={() => setPage((p) => p + 1)}
          disabled={page >= (data?.pageCount ?? 1)}
        >
          次へ
        </button>
      </div>
    </div>
  );
}

prefetchQuery を使うことで、ユーザーが次のページに移動する前にデータを先読みできます。これにより、ページ遷移時のローディング時間を大幅に削減できるでしょう。

無限スクロールの実装

Twitter や Instagram のような無限スクロールも、useInfiniteQuery を使えば簡単に実装できます。

typescript// useInfiniteQuery をインポート
import { useInfiniteQuery } from '@tanstack/react-query';
typescript// 無限スクロール用のデータ取得関数
const fetchInfiniteUsers = async ({ pageParam = 1 }) => {
  const response = await fetch(
    `/api/users?page=${pageParam}&limit=20`
  );
  if (!response.ok)
    throw new Error('データの取得に失敗しました');
  return response.json();
};
typescript// 無限スクロールコンポーネント
function InfiniteUserList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
  } = useInfiniteQuery({
    queryKey: ['users-infinite'],
    queryFn: fetchInfiniteUsers,
    // 次のページ番号を取得
    getNextPageParam: (lastPage) => {
      return lastPage.currentPage < lastPage.pageCount
        ? lastPage.currentPage + 1
        : undefined;
    },
    // 初期ページ番号
    initialPageParam: 1,
  });

  if (isLoading) return <div>読み込み中...</div>;

  return (
    <div>
      {data?.pages.map((page, i) => (
        <div key={i}>
          {page.data.map((user: User) => (
            <div key={user.id}>
              <h3>{user.name}</h3>
              <p>{user.email}</p>
            </div>
          ))}
        </div>
      ))}

      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage
          ? '読み込み中...'
          : hasNextPage
          ? 'さらに読み込む'
          : 'すべて表示されました'}
      </button>
    </div>
  );
}

useInfiniteQuery は、ページデータを配列として管理し、fetchNextPage を呼び出すたびに新しいページを追加していきます。hasNextPage で次のページの有無を判定できるため、実装がシンプルになります。

カスタムフックでの再利用

共通のデータ取得ロジックはカスタムフックにまとめることで、コードの再利用性が向上します。

typescript// カスタムフック: ユーザー一覧取得
export function useUsers() {
  return useQuery({
    queryKey: ['users'],
    queryFn: async () => {
      const res = await fetch('/api/users');
      if (!res.ok)
        throw new Error('ユーザー一覧の取得に失敗');
      return res.json();
    },
    staleTime: 5 * 60 * 1000,
  });
}
typescript// カスタムフック: 個別ユーザー取得
export function useUser(userId: number) {
  return useQuery({
    queryKey: ['user', userId],
    queryFn: async () => {
      const res = await fetch(`/api/users/${userId}`);
      if (!res.ok)
        throw new Error('ユーザー情報の取得に失敗');
      return res.json();
    },
    enabled: !!userId, // userId が存在する場合のみ実行
  });
}
typescript// カスタムフック: ユーザー作成
export function useCreateUser() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (newUser: Omit<User, 'id'>) => {
      const res = await fetch('/api/users', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(newUser),
      });
      if (!res.ok) throw new Error('ユーザーの作成に失敗');
      return res.json();
    },
    onSuccess: () => {
      // ユーザー一覧を再取得
      queryClient.invalidateQueries({
        queryKey: ['users'],
      });
    },
  });
}
typescript// カスタムフックの使用例
function UserManagement() {
  const { data: users, isLoading } = useUsers();
  const createUser = useCreateUser();

  const handleCreate = (userData: Omit<User, 'id'>) => {
    createUser.mutate(userData);
  };

  if (isLoading) return <div>読み込み中...</div>;

  return (
    <div>
      <h2>ユーザー管理</h2>
      <ul>
        {users?.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
      {/* フォームコンポーネント */}
    </div>
  );
}

カスタムフックを使うことで、コンポーネントはデータ取得の詳細を知る必要がなくなります。これにより、テストもしやすくなるでしょう。

DevTools の活用

TanStack Query DevTools を使うと、クエリの状態をリアルタイムで確認できます。

bashyarn add @tanstack/react-query-devtools
typescript// DevTools のインポート
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
typescript// アプリケーションに DevTools を追加
function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <UserManagement />
      {/* 開発環境でのみ表示される */}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

DevTools を使うと、以下の情報を確認できます。

#確認できる情報用途
1クエリの状態データが fresh か stale かを確認
2キャッシュの内容保存されているデータを確認
3クエリの実行履歴いつデータが取得されたかを追跡
4エラー情報失敗したクエリの詳細を確認

DevTools は開発時のデバッグに非常に役立ちます。本番環境では自動的に除外されるため、安心して使えるでしょう。

まとめ

TanStack Query を使うことで、React アプリケーションにおけるデータ取得が劇的にシンプルになります。本記事で解説した内容をまとめましょう。

TanStack Query の主な利点は以下の通りです。

  • コード量の削減: ローディング、エラー、データの状態管理が自動化される
  • 自動キャッシュ: 同じデータへの複数回の API 呼び出しを防ぐ
  • 柔軟な設定: staleTime や gcTime で細かいキャッシュ戦略を設定可能
  • 楽観的更新: UI を即座に更新して、ユーザー体験を向上
  • 強力な機能: ページネーション、無限スクロール、先読みなどが簡単に実装できる

実装の際は、以下のポイントを意識すると良いでしょう。

  1. queryKey の設計: 一意で分かりやすいキーを使う
  2. 適切なキャッシュ時間: データの性質に応じて staleTime を調整する
  3. カスタムフック化: 共通のロジックは再利用可能にする
  4. エラーハンドリング: ユーザーにわかりやすいエラーメッセージを表示する
  5. DevTools の活用: 開発時は DevTools でクエリの状態を確認する

TanStack Query は、単なるデータ取得ライブラリではありません。サーバーステート管理の複雑さを抽象化し、開発者がビジネスロジックに集中できる環境を提供してくれます。ぜひプロジェクトに導入して、その威力を体感してみてください。

初めは useQuery と useMutation から始めて、徐々に高度な機能を取り入れていくことをお勧めします。本記事が、あなたの React 開発をより快適にする手助けになれば幸いです。

関連リンク