Tasuke Hubのロゴ

ITを中心に困っている人を助けるメディア

分かりやすく解決策を提供することで、あなたの困ったをサポート。 全ての人々がスムーズに生活できる世界を目指します。

TanStack Query(React Query)完全ガイド!データフェッチングとキャッシュ戦略の実践的な使い方

記事のサムネイル

TanStack Queryとは何か

TanStack Query(旧React Query)は、Reactアプリケーションでサーバーサイドのデータを効率的に管理するためのライブラリです。従来のfetchやaxiosを使った単純なデータ取得と比べて、以下のような強力な機能を提供しています。

主な特徴:

  • 自動キャッシュ管理: 一度取得したデータを自動でキャッシュし、同じデータへの重複リクエストを防止
  • バックグラウンド更新: ユーザーが気づかないうちにデータを最新状態に保つ
  • エラーハンドリング: 統一されたエラー処理とリトライ機能
  • ローディング状態: データ取得中の状態を簡単に管理

従来の方法との比較:

// 従来のuseEffectを使った方法
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
  fetch('/api/users')
    .then(res => res.json())
    .then(setData)
    .catch(setError)
    .finally(() => setLoading(false));
}, []);

// TanStack Queryを使った方法
const { data, isLoading, error } = useQuery({
  queryKey: ['users'],
  queryFn: () => fetch('/api/users').then(res => res.json())
});

このように、TanStack Queryを使うことで複雑な状態管理を大幅に簡素化できます。

TanStack Queryのセットアップと基本設定

TanStack Queryを始めるには、まずパッケージをインストールし、アプリケーション全体でQueryClientを設定する必要があります。

インストール:

# npm を使用する場合
npm install @tanstack/react-query

# yarn を使用する場合
yarn add @tanstack/react-query

基本的なセットアップ:

// App.js または _app.js
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

// QueryClientインスタンスを作成
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 5 * 60 * 1000, // 5分間はデータを新鮮として扱う
      cacheTime: 10 * 60 * 1000, // 10分間キャッシュを保持
      retry: 1, // 失敗時のリトライ回数
      refetchOnWindowFocus: false, // ウィンドウフォーカス時の再取得を無効化
    },
  },
});

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <YourAppComponents />
      {/* 開発時のデバッグツール */}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

export default App;

Next.jsでの設定例:

// _app.js
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';

export default function MyApp({ Component, pageProps }) {
  const [queryClient] = useState(() => new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60 * 1000, // 1分
      },
    },
  }));

  return (
    <QueryClientProvider client={queryClient}>
      <Component {...pageProps} />
    </QueryClientProvider>
  );
}

このセットアップにより、アプリケーション全体でTanStack Queryの機能を使用できるようになります。

useQueryフックでデータを取得する方法

useQueryは、サーバーからデータを取得するためのメインとなるフックです。基本的な使い方から実践的なパターンまで、順を追って解説します。

基本的な使い方:

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

function UsersList() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['users'],
    queryFn: async () => {
      const response = await fetch('/api/users');
      if (!response.ok) {
        throw new Error('ユーザー取得に失敗しました');
      }
      return response.json();
    }
  });

  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>
  );
}

動的なクエリキーの使用:

function UserProfile({ userId }) {
  const { data: user, isLoading } = useQuery({
    queryKey: ['user', userId], // userIdが変わるとクエリが再実行される
    queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json()),
    enabled: !!userId, // userIdが存在する場合のみクエリを実行
  });

  if (isLoading) return <div>ユーザー情報を読み込み中...</div>;

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

条件付きクエリとカスタム設定:

function PostsList({ category, page = 1 }) {
  const { 
    data, 
    isLoading, 
    isFetching, 
    isError 
  } = useQuery({
    queryKey: ['posts', category, page],
    queryFn: async () => {
      const response = await fetch(`/api/posts?category=${category}&page=${page}`);
      return response.json();
    },
    enabled: !!category, // categoryが選択されている場合のみ実行
    staleTime: 2 * 60 * 1000, // 2分間は新鮮なデータとして扱う
    cacheTime: 5 * 60 * 1000, // 5分間キャッシュを保持
    retry: 3, // 失敗時に3回リトライ
    keepPreviousData: true, // ページング時に前のデータを保持
  });

  return (
    <div>
      {isFetching && <div>更新中...</div>}
      {isError && <div>投稿の取得に失敗しました</div>}
      {data?.posts?.map(post => (
        <article key={post.id}>
          <h3>{post.title}</h3>
          <p>{post.excerpt}</p>
        </article>
      ))}
    </div>
  );
}

axiosを使った実装例:

import axios from 'axios';

const api = axios.create({
  baseURL: 'https://api.example.com',
  timeout: 5000,
});

function ProductList() {
  const { data: products, isLoading, error } = useQuery({
    queryKey: ['products'],
    queryFn: async () => {
      const { data } = await api.get('/products');
      return data;
    },
    onError: (error) => {
      console.error('商品取得エラー:', error);
    },
    onSuccess: (data) => {
      console.log('商品取得成功:', data);
    }
  });

  // レンダリング処理...
}

このように、useQueryは様々な設定オプションを組み合わせることで、柔軟なデータ取得処理を実現できます。

useMutationでデータを更新する実装

useMutationは、データの作成、更新、削除などの副作用を伴う操作を行うためのフックです。サーバーのデータを変更した後に、適切にキャッシュを更新する方法を学びましょう。

基本的なuseMutationの使い方:

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

function CreateUserForm() {
  const queryClient = useQueryClient();
  
  const createUserMutation = useMutation({
    mutationFn: async (userData) => {
      const response = await fetch('/api/users', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(userData),
      });
      
      if (!response.ok) {
        throw new Error('ユーザー作成に失敗しました');
      }
      
      return response.json();
    },
    onSuccess: () => {
      // 成功時にユーザーリストのキャッシュを無効化して再取得
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
    onError: (error) => {
      console.error('ユーザー作成エラー:', error);
    }
  });

  const handleSubmit = (formData) => {
    createUserMutation.mutate({
      name: formData.name,
      email: formData.email,
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      {createUserMutation.isLoading && <p>作成中...</p>}
      {createUserMutation.isError && (
        <p>エラー: {createUserMutation.error.message}</p>
      )}
      {createUserMutation.isSuccess && <p>ユーザーが作成されました!</p>}
      {/* フォームフィールド */}
    </form>
  );
}

Optimistic Updates(楽観的更新)の実装:

function TodoList() {
  const queryClient = useQueryClient();

  const toggleTodoMutation = useMutation({
    mutationFn: async ({ id, completed }) => {
      const response = await fetch(`/api/todos/${id}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ completed }),
      });
      return response.json();
    },
    // 楽観的更新:APIレスポンスを待たずに即座にUIを更新
    onMutate: async ({ id, completed }) => {
      // 進行中のクエリをキャンセル
      await queryClient.cancelQueries({ queryKey: ['todos'] });

      // 現在のデータを取得
      const previousTodos = queryClient.getQueryData(['todos']);

      // 楽観的にデータを更新
      queryClient.setQueryData(['todos'], old => 
        old.map(todo => 
          todo.id === id ? { ...todo, completed } : todo
        )
      );

      // ロールバック用に以前のデータを返す
      return { previousTodos };
    },
    onError: (err, variables, context) => {
      // エラー時にロールバック
      if (context?.previousTodos) {
        queryClient.setQueryData(['todos'], context.previousTodos);
      }
    },
    onSettled: () => {
      // 成功・失敗に関わらず最終的にデータを再取得
      queryClient.invalidateQueries({ queryKey: ['todos'] });
    }
  });

  // 使用例
  const handleToggle = (todo) => {
    toggleTodoMutation.mutate({
      id: todo.id,
      completed: !todo.completed
    });
  };
}

データ削除とキャッシュ更新:

function UserItem({ user }) {
  const queryClient = useQueryClient();

  const deleteUserMutation = useMutation({
    mutationFn: async (userId) => {
      await fetch(`/api/users/${userId}`, { method: 'DELETE' });
    },
    onSuccess: (_, deletedUserId) => {
      // キャッシュから削除されたユーザーを除去
      queryClient.setQueryData(['users'], (oldUsers) =>
        oldUsers.filter(user => user.id !== deletedUserId)
      );
    }
  });

  return (
    <div>
      <span>{user.name}</span>
      <button 
        onClick={() => deleteUserMutation.mutate(user.id)}
        disabled={deleteUserMutation.isLoading}
      >
        {deleteUserMutation.isLoading ? '削除中...' : '削除'}
      </button>
    </div>
  );
}

複数のクエリを同時に更新:

function UpdatePostForm({ postId }) {
  const queryClient = useQueryClient();

  const updatePostMutation = useMutation({
    mutationFn: async (postData) => {
      const response = await fetch(`/api/posts/${postId}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(postData),
      });
      return response.json();
    },
    onSuccess: (updatedPost) => {
      // 投稿詳細のキャッシュを更新
      queryClient.setQueryData(['post', postId], updatedPost);
      
      // 投稿一覧のキャッシュも更新
      queryClient.setQueryData(['posts'], (oldPosts) =>
        oldPosts.map(post =>
          post.id === postId ? updatedPost : post
        )
      );
      
      // 関連するクエリを無効化
      queryClient.invalidateQueries({ queryKey: ['posts', 'recent'] });
    }
  });
}

useMutationを適切に使用することで、ユーザー体験を向上させる高速で信頼性の高いデータ更新処理を実現できます。

エラーハンドリングとローディング状態の管理

TanStack Queryでは、エラーハンドリングとローディング状態を効率的に管理できます。実用的なパターンとベストプラクティスを紹介します。

基本的なエラーハンドリング:

function UserProfile({ userId }) {
  const { 
    data, 
    isLoading, 
    error, 
    isError,
    refetch 
  } = useQuery({
    queryKey: ['user', userId],
    queryFn: async () => {
      const response = await fetch(`/api/users/${userId}`);
      
      if (!response.ok) {
        // HTTPステータスに応じた詳細なエラー情報
        const errorData = await response.json();
        throw new Error(errorData.message || `エラー: ${response.status}`);
      }
      
      return response.json();
    },
    retry: (failureCount, error) => {
      // 404エラーはリトライしない
      if (error.message.includes('404')) return false;
      // 3回までリトライ
      return failureCount < 3;
    },
    retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000)
  });

  if (isLoading) {
    return (
      <div className="loading-container">
        <div className="spinner"></div>
        <p>ユーザー情報を読み込み中...</p>
      </div>
    );
  }

  if (isError) {
    return (
      <div className="error-container">
        <h3>エラーが発生しました</h3>
        <p>{error.message}</p>
        <button onClick={() => refetch()}>再試行</button>
      </div>
    );
  }

  return <div>{data?.name}</div>;
}

グローバルエラーハンドリング:

// QueryClientでグローバルエラーハンドラーを設定
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      onError: (error) => {
        // トースト通知を表示
        toast.error(`データ取得エラー: ${error.message}`);
        
        // エラーログを送信
        console.error('Query Error:', error);
      },
      retry: (failureCount, error) => {
        // ネットワークエラーの場合のみリトライ
        if (error.name === 'NetworkError') return failureCount < 2;
        return false;
      }
    },
    mutations: {
      onError: (error, variables, context) => {
        toast.error(`操作に失敗しました: ${error.message}`);
        console.error('Mutation Error:', error, variables);
      }
    }
  }
});

高度なローディング状態管理:

function DataDashboard() {
  const usersQuery = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  });

  const postsQuery = useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
  });

  const statsQuery = useQuery({
    queryKey: ['stats'],
    queryFn: fetchStats,
    enabled: usersQuery.isSuccess && postsQuery.isSuccess
  });

  // 複数クエリの状態を統合
  const isLoading = usersQuery.isLoading || postsQuery.isLoading;
  const hasError = usersQuery.isError || postsQuery.isError;
  const isRefetching = usersQuery.isFetching || postsQuery.isFetching;

  if (isLoading) {
    return (
      <div className="dashboard-loading">
        <div className="loading-items">
          <div className={`loading-item ${usersQuery.isLoading ? 'active' : 'complete'}`}>
            ユーザーデータ読み込み中...
          </div>
          <div className={`loading-item ${postsQuery.isLoading ? 'active' : 'complete'}`}>
            投稿データ読み込み中...
          </div>
        </div>
      </div>
    );
  }

  return (
    <div className="dashboard">
      {isRefetching && (
        <div className="refresh-indicator">データを更新中...</div>
      )}
      
      {hasError && (
        <div className="error-banner">
          一部のデータの取得に失敗しました
        </div>
      )}
      
      {/* ダッシュボードコンテンツ */}
    </div>
  );
}

カスタムフックでローディング状態を抽象化:

function useApiState(queries) {
  const states = queries.map(query => ({
    isLoading: query.isLoading,
    isError: query.isError,
    isFetching: query.isFetching,
    error: query.error,
  }));

  return {
    isLoading: states.some(state => state.isLoading),
    isError: states.some(state => state.isError),
    isFetching: states.some(state => state.isFetching),
    errors: states.filter(state => state.error).map(state => state.error),
    loadingCount: states.filter(state => state.isLoading).length,
    errorCount: states.filter(state => state.isError).length,
  };
}

// 使用例
function MyComponent() {
  const usersQuery = useQuery({ queryKey: ['users'], queryFn: fetchUsers });
  const postsQuery = useQuery({ queryKey: ['posts'], queryFn: fetchPosts });
  
  const apiState = useApiState([usersQuery, postsQuery]);

  if (apiState.isLoading) {
    return <div>読み込み中... ({apiState.loadingCount}/2)</div>;
  }

  if (apiState.isError) {
    return (
      <div>
        エラーが発生しました ({apiState.errorCount}件)
        {apiState.errors.map((error, i) => (
          <p key={i}>{error.message}</p>
        ))}
      </div>
    );
  }
}

Error Boundaryとの連携:

import { ErrorBoundary } from 'react-error-boundary';

function ErrorFallback({ error, resetErrorBoundary }) {
  return (
    <div className="error-fallback">
      <h2>予期しないエラーが発生しました</h2>
      <pre>{error.message}</pre>
      <button onClick={resetErrorBoundary}>再試行</button>
    </div>
  );
}

function App() {
  const queryClient = useQueryClient();

  return (
    <ErrorBoundary
      FallbackComponent={ErrorFallback}
      onReset={() => {
        // すべてのクエリを再取得
        queryClient.invalidateQueries();
      }}
    >
      <YourAppComponents />
    </ErrorBoundary>
  );
}

これらのパターンを組み合わせることで、ユーザーフレンドリーで堅牢なエラーハンドリングシステムを構築できます。

キャッシュ戦略とパフォーマンス最適化の実践

TanStack Queryの真価は、効率的なキャッシュ戦略にあります。適切なキャッシュ設定により、アプリケーションのパフォーマンスを大幅に向上させることができます。

staleTimeとcacheTimeの理解:

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // staleTime: データが「古い」とみなされるまでの時間
      staleTime: 5 * 60 * 1000, // 5分間は新鮮なデータとして扱う
      
      // cacheTime: キャッシュされたデータが保持される時間
      cacheTime: 10 * 60 * 1000, // 10分間メモリに保持
      
      // refetchOnWindowFocus: ウィンドウフォーカス時の再取得
      refetchOnWindowFocus: false,
      
      // refetchOnReconnect: ネットワーク再接続時の再取得
      refetchOnReconnect: true,
    },
  },
});

データタイプ別キャッシュ戦略:

// ユーザープロフィール(変更頻度低)
const { data: userProfile } = useQuery({
  queryKey: ['user', userId],
  queryFn: fetchUserProfile,
  staleTime: 30 * 60 * 1000, // 30分
  cacheTime: 60 * 60 * 1000,  // 1時間
});

// ニュースフィード(リアルタイム性重視)
const { data: newsFeed } = useQuery({
  queryKey: ['news'],
  queryFn: fetchNews,
  staleTime: 30 * 1000,     // 30秒
  cacheTime: 5 * 60 * 1000,  // 5分
  refetchInterval: 60 * 1000, // 1分間隔で自動更新
});

// 商品カタログ(比較的安定)
const { data: products } = useQuery({
  queryKey: ['products', category],
  queryFn: () => fetchProducts(category),
  staleTime: 10 * 60 * 1000, // 10分
  cacheTime: 30 * 60 * 1000, // 30分
});

プリフェッチによる先読み戦略:

function ProductCatalog() {
  const queryClient = useQueryClient();
  
  const { data: currentProducts } = useQuery({
    queryKey: ['products', currentPage],
    queryFn: () => fetchProducts(currentPage),
  });

  // ページネーション用の先読み
  useEffect(() => {
    if (currentPage < totalPages) {
      queryClient.prefetchQuery({
        queryKey: ['products', currentPage + 1],
        queryFn: () => fetchProducts(currentPage + 1),
        staleTime: 10 * 60 * 1000,
      });
    }
  }, [currentPage, queryClient, totalPages]);

  // マウスホバーで商品詳細を先読み
  const handleProductHover = (productId) => {
    queryClient.prefetchQuery({
      queryKey: ['product', productId],
      queryFn: () => fetchProductDetail(productId),
    });
  };

  return (
    <div>
      {currentProducts?.map(product => (
        <div 
          key={product.id}
          onMouseEnter={() => handleProductHover(product.id)}
        >
          {product.name}
        </div>
      ))}
    </div>
  );
}

インテリジェントな依存関係管理:

function UserDashboard({ userId }) {
  // ユーザー基本情報
  const userQuery = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  // ユーザーの投稿(ユーザー情報取得後に実行)
  const postsQuery = useQuery({
    queryKey: ['posts', userId],
    queryFn: () => fetchUserPosts(userId),
    enabled: !!userQuery.data?.id,
    staleTime: 2 * 60 * 1000,
  });

  // ユーザーの設定(必要時のみ)
  const settingsQuery = useQuery({
    queryKey: ['settings', userId],
    queryFn: () => fetchUserSettings(userId),
    enabled: false, // 手動で実行
  });

  const loadSettings = () => {
    settingsQuery.refetch();
  };

  return (
    <div>
      {userQuery.data && (
        <>
          <UserProfile user={userQuery.data} />
          <button onClick={loadSettings}>設定を読み込む</button>
        </>
      )}
    </div>
  );
}

メモリ使用量の最適化:

// 無限スクロール用の最適化
function InfinitePostsList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: ({ pageParam = 0 }) => fetchPosts(pageParam),
    getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
    // 古いページのデータを自動的に削除
    cacheTime: 5 * 60 * 1000, // 5分
    // 最大ページ数を制限してメモリ使用量を抑制
    maxPages: 10,
  });

  return (
    <div>
      {data?.pages.map((page, i) => (
        <div key={i}>
          {page.posts.map(post => (
            <div key={post.id}>{post.title}</div>
          ))}
        </div>
      ))}
      
      {hasNextPage && (
        <button onClick={fetchNextPage} disabled={isFetchingNextPage}>
          {isFetchingNextPage ? '読み込み中...' : 'さらに読み込む'}
        </button>
      )}
    </div>
  );
}

キャッシュの手動管理:

function CacheManager() {
  const queryClient = useQueryClient();

  const clearUserCache = () => {
    // 特定のユーザーのキャッシュをクリア
    queryClient.removeQueries({ queryKey: ['user'] });
  };

  const refreshAllData = () => {
    // すべてのクエリを無効化して再取得
    queryClient.invalidateQueries();
  };

  const preloadCriticalData = () => {
    // 重要なデータを事前読み込み
    queryClient.prefetchQuery({
      queryKey: ['user', 'current'],
      queryFn: fetchCurrentUser,
    });
    
    queryClient.prefetchQuery({
      queryKey: ['notifications'],
      queryFn: fetchNotifications,
    });
  };

  return (
    <div>
      <button onClick={clearUserCache}>ユーザーキャッシュクリア</button>
      <button onClick={refreshAllData}>全データ更新</button>
      <button onClick={preloadCriticalData}>重要データ先読み</button>
    </div>
  );
}

パフォーマンス監視:

// React Query Devtoolsを活用
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <YourApp />
      {/* 開発環境でのみ表示 */}
      {process.env.NODE_ENV === 'development' && (
        <ReactQueryDevtools initialIsOpen={false} />
      )}
    </QueryClientProvider>
  );
}

// カスタム監視フック
function useQueryMetrics() {
  const queryClient = useQueryClient();
  
  return {
    cacheSize: queryClient.getQueryCache().getAll().length,
    clearCache: () => queryClient.clear(),
    getCacheData: (key) => queryClient.getQueryData(key),
  };
}

適切なキャッシュ戦略により、ネットワークリクエストを最小限に抑え、ユーザー体験を大幅に向上させることができます。データの特性を理解し、用途に応じて最適な設定を選択することが重要です。

TanStack Queryをマスターすることで、Reactアプリケーションのデータフェッチング処理が格段に効率化され、保守性の高いコードベースを構築できるようになります。

TH

Tasuke Hub管理人

東証プライム市場上場企業エンジニア

情報系修士卒業後、大手IT企業にてフルスタックエンジニアとして活躍。 Webアプリケーション開発からクラウドインフラ構築まで幅広い技術に精通し、 複数のプロジェクトでリードエンジニアを担当。 技術ブログやオープンソースへの貢献を通じて、日本のIT技術コミュニティに積極的に関わっている。

🎓情報系修士🏢東証プライム上場企業💻フルスタックエンジニア📝技術ブログ執筆者

このトピックはこちらの書籍で勉強するのがおすすめ!

この記事の内容をさらに深く理解したい方におすすめの一冊です。実践的な知識を身につけたい方は、ぜひチェックしてみてください!

おすすめ記事

おすすめコンテンツ