MENU

TanStack Query(React Query)完全ガイド – データフェッチングの最適化

目次

TanStack Query(React Query)完全ガイド – データフェッチングの最適化

API 連携の複雑さを解決する

React でのデータフェッチング(API 連携)は、一見シンプルに見えて、実は多くの課題を抱えています。「キャッシュをどう管理するか」「ローディング中の UI をどう表示するか」「エラー時の再試行をどうするか」「バックグラウンド更新をどう実装するか」——こうした実装パターンは、プロジェクトごとに何度も繰り返されます。

今回は、これらの課題を一括で解決するTanStack Query(旧 React Query)について、基本から応用実装まで、詳しくまとめておきたいと思います。

対象となる方

  • React で API 連携しているが、データ管理が複雑になっている方
  • キャッシング、バックグラウンド更新を実装したい方
  • 大規模な API 連携アプリケーション開発を行う方

 ※ このドキュメントは TanStack Query v5 以上で書いていきます。

TanStack Query の基本

インストールと初期設定

npm install @tanstack/react-query
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient();

export function App() {
  return (
    <QueryClientProvider client={queryClient}>
      {/* Your app */}
    </QueryClientProvider>
  );
}

useQuery の基本

useQuery は、サーバーからデータを取得し、キャッシュしてくれる hook です。

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

interface Post {
  id: number;
  title: string;
}

export function Posts() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['posts'],
    queryFn: async () => {
      const response = await fetch('/api/posts');
      return response.json() as Promise<Post[]>;
    },
  });

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <ul>
      {data?.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

queryKey の重要性

queryKey はキャッシュ管理の基本です。配列の形式で複数のキーを組み合わせることで、細かいキャッシュ制御が可能になります。

// ✅ 良い例:パラメータを含める
const { data } = useQuery({
  queryKey: ['posts', { page: 1, limit: 10 }],
  queryFn: () => fetch('/api/posts?page=1&limit=10').then(r => r.json()),
});

// パラメータが異なれば別のキャッシュ
const { data: page2 } = useQuery({
  queryKey: ['posts', { page: 2, limit: 10 }],
  queryFn: () => fetch('/api/posts?page=2&limit=10').then(r => r.json()),
});

useMutation – データ変更

useMutation の基本

useMutation は、POST、PUT、DELETE などのデータ変更操作に使用します。

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

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

  const mutation = useMutation({
    mutationFn: async (newPost: { title: string }) => {
      const response = await fetch('/api/posts', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(newPost),
      });
      return response.json();
    },
    onSuccess: () => {
      // 成功時にキャッシュを無効化
      queryClient.invalidateQueries({ queryKey: ['posts'] });
      setTitle('');
    },
  });

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

  return (
    <form onSubmit={handleSubmit}>
      <input value={title} onChange={(e) => setTitle(e.target.value)} />
      <button disabled={mutation.isPending}>
        {mutation.isPending ? '投稿中...' : '投稿'}
      </button>
    </form>
  );
}

キャッシング戦略

staleTime と cacheTime

TanStack Query のキャッシング動作は、staleTime(有効期間)と cacheTime(メモリに保持する期間)で制御します。

const { data } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
  staleTime: 1000 * 60 * 5,   // 5分間は fresh(再取得しない)
  gcTime: 1000 * 60 * 10,      // 10分間メモリに保持
});

staleTime と gcTime の違い

項目 説明 デフォルト
staleTime データが fresh 状態の期間。この間は再取得しない 0ms(常に stale)
gcTime 使用されないクエリをメモリに保持する期間 5分

invalidateQueries でキャッシュを無効化

const queryClient = useQueryClient();

// 特定のキーを無効化
queryClient.invalidateQueries({ queryKey: ['posts'] });

// 部分一致で無効化
queryClient.invalidateQueries({
  queryKey: ['posts'],
  exact: false,  // 'posts' を含むすべてのキーが対象
});

// useMutation の onSuccess で活用
const mutation = useMutation({
  mutationFn: (newPost) => createPost(newPost),
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['posts'] });
  },
});

Background Refetching と Polling

バックグラウンド更新

データが stale 状態になると、バックグラウンドで自動的に再取得されます。

const { data, isFetching } = useQuery({
  queryKey: ['posts'],
  queryFn: () => fetch('/api/posts').then(r => r.json()),
  staleTime: 1000 * 60,  // 1分後に stale
  refetchOnWindowFocus: true,  // ウィンドウがフォーカスされたら再取得
  refetchOnMount: true,  // コンポーネントがマウントされたら再取得
});

return (
  <div>
    {isFetching && <p>Updating...</p>}
    {/* データ表示 */}
  </div>
);

Polling:定期的な更新

const { data } = useQuery({
  queryKey: ['stock-price'],
  queryFn: () => fetch('/api/stock-price').then(r => r.json()),
  refetchInterval: 1000 * 5,  // 5秒ごとに再取得
  refetchIntervalInBackground: true,  // タブがバックグラウンドでも再取得
});

エラーハンドリングと再試行

retry の設定

ネットワークエラーの場合、自動的に再試行できます。

const { data, error } = useQuery({
  queryKey: ['posts'],
  queryFn: async () => {
    const response = await fetch('/api/posts');
    if (!response.ok) throw new Error('Failed to fetch');
    return response.json();
  },
  retry: 3,  // 失敗時に最大3回まで再試行
  retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
  // 指数バックオフ:1秒、2秒、4秒...
});

カスタムエラーハンドリング

const { data, error, isError } = useQuery({
  queryKey: ['user'],
  queryFn: () => fetch('/api/user').then(r => r.json()),
  retry: (failureCount, error) => {
    // 特定のステータスコードは再試行しない
    if (error instanceof Response && error.status === 404) {
      return false;
    }
    return failureCount < 3;
  },
});

if (isError) {
  return <div>Error loading user: {error?.message}</div>;
}

実装例:ページング

ページング実装

export function PaginatedPosts() {
  const [page, setPage] = useState(1);

  const { data, isLoading, isPreviousData } = useQuery({
    queryKey: ['posts', page],
    queryFn: () => fetch(`/api/posts?page=${page}`).then(r => r.json()),
    placeholderData: (previousData) => previousData,  // ページ遷移時に前のデータを表示
  });

  return (
    <>
      {data?.posts.map((post) => (
        <div key={post.id} style={{ opacity: isPreviousData ? 0.5 : 1 }}>
          {post.title}
        </div>
      ))}
      <button onClick={() => setPage((p) => Math.max(p - 1, 1))}>
        Previous
      </button>
      <button onClick={() => setPage((p) => p + 1)}>Next</button>
    </>
  );
}

実装例:無限スクロール

useInfiniteQuery でのスクロール実装

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

export function InfinitePosts() {
  const {
    data,
    hasNextPage,
    fetchNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: async ({ pageParam = 1 }) => {
      const response = await fetch(`/api/posts?page=${pageParam}`);
      return response.json();
    },
    getNextPageParam: (lastPage, pages) => {
      return lastPage.hasMore ? pages.length + 1 : undefined;
    },
  });

  const sentinelRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const observer = new IntersectionObserver((entries) => {
      if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
        fetchNextPage();
      }
    });

    if (sentinelRef.current) {
      observer.observe(sentinelRef.current);
    }

    return () => observer.disconnect();
  }, [hasNextPage, isFetchingNextPage, fetchNextPage]);

  return (
    <>
      {data?.pages.map((page) =>
        page.posts.map((post) => <div key={post.id}>{post.title}</div>)
      )}
      <div ref={sentinelRef} style={{ height: '100px' }}>
        {isFetchingNextPage ? 'Loading more...' : ''}
      </div>
    </>
  );
}

フォーム送信との連携

Optimistic Update

API リクエスト前にローカルで状態を更新し、レスポンス待機中も UI が反応的に見えるようにします。

export function UpdateUserForm({ userId }: { userId: number }) {
  const queryClient = useQueryClient();
  const [name, setName] = useState('');

  const mutation = useMutation({
    mutationFn: async (newName: string) => {
      const response = await fetch(`/api/users/${userId}`, {
        method: 'PUT',
        body: JSON.stringify({ name: newName }),
      });
      return response.json();
    },
    onMutate: async (newName) => {
      // キャッシュを一時的に更新(Optimistic Update)
      await queryClient.cancelQueries({ queryKey: ['user', userId] });
      const previousData = queryClient.getQueryData(['user', userId]);
      queryClient.setQueryData(['user', userId], (old: any) => ({
        ...old,
        name: newName,
      }));

      return { previousData };
    },
    onError: (error, newName, context) => {
      // エラー時はロールバック
      queryClient.setQueryData(
        ['user', userId],
        context?.previousData
      );
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['user', userId] });
    },
  });

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

  return (
    <form onSubmit={handleSubmit}>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <button disabled={mutation.isPending}>Update</button>
    </form>
  );
}

DevTools でデバッグ

npm install @tanstack/react-query-devtools
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

export function App() {
  return (
    <QueryClientProvider client={queryClient}>
      {/* Your app */}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

まとめ

以上で TanStack Query 完全ガイドを終えたいと思います。

TanStack Query は、React でのデータフェッチングを大幅に簡潔にし、キャッシング、バックグラウンド更新、エラーハンドリングなどの複雑な実装パターンを一気に解決してくれます。useQuery、useMutation、useInfiniteQuery などの hooks を理解することで、大規模な API 連携アプリケーションでも、保守性高いコード構造が実現できます。

ぜひ TanStack Query をプロジェクトに導入して、データフェッチング周りの実装コストを削減し、機能開発に集中しましょう。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次