TanStack Query実践ガイド:サーバー状態管理の決定版

TanStack Query(旧React Query)を使ったサーバー状態管理の実践的なパターンを、キャッシュ戦略・楽観的更新・無限スクロールまで網羅的に解説します。

tanstack
react
typescript
frontend
state-management

TanStack Query実践ガイド:サーバー状態管理の決定版

フロントエンド開発において、サーバーから取得したデータの状態管理は長年の課題でした。TanStack Query(旧React Query)はその問題を根本から解決し、現在では最もポピュラーなサーバー状態管理ライブラリの一つです。

サーバー状態とクライアント状態の違い

多くの開発者がReduxやZustandでAPIデータを管理しますが、サーバー状態には独自の特性があります:

  • 非同期性:データ取得に時間がかかる
  • 共有性:複数のユーザーが同じデータを参照・変更する
  • 失効性:時間が経つとデータが古くなる
  • キャッシュ可能性:同じデータを繰り返し取得するのは非効率

TanStack Queryはこれらをすべて自動で処理します。

セットアップ

npm install @tanstack/react-query @tanstack/react-query-devtools
// app/providers.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5, // 5分間はキャッシュを新鮮とみなす
      retry: 2,
    },
  },
});

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

基本的なデータフェッチ

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

interface User {
  id: number;
  name: string;
  email: string;
}

async function fetchUser(id: number): Promise<User> {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error('ユーザーの取得に失敗しました');
  return res.json();
}

function UserProfile({ userId }: { userId: number }) {
  const { data, isLoading, isError, error } = useQuery({
    queryKey: ['users', userId],
    queryFn: () => fetchUser(userId),
  });

  if (isLoading) return <div>読み込み中...</div>;
  if (isError) return <div>エラー: {error.message}</div>;

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

queryKeyはキャッシュのキーとなります。userIdが変わると自動的に再フェッチします。

データの変更:useMutation

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

async function updateUser(id: number, data: Partial<User>): Promise<User> {
  const res = await fetch(`/api/users/${id}`, {
    method: 'PATCH',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  });
  return res.json();
}

function EditUserForm({ userId }: { userId: number }) {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: (data: Partial<User>) => updateUser(userId, data),
    onSuccess: (updatedUser) => {
      // キャッシュを最新データで更新
      queryClient.setQueryData(['users', userId], updatedUser);
    },
  });

  return (
    <button
      onClick={() => mutation.mutate({ name: '新しい名前' })}
      disabled={mutation.isPending}
    >
      {mutation.isPending ? '更新中...' : '名前を更新'}
    </button>
  );
}

楽観的更新(Optimistic Updates)

UXを向上させるために、サーバーの応答を待たずにUIを先に更新する手法です。

const mutation = useMutation({
  mutationFn: updateUser,
  onMutate: async (newData) => {
    // 進行中のフェッチをキャンセル
    await queryClient.cancelQueries({ queryKey: ['users', userId] });

    // 現在のキャッシュを保存(ロールバック用)
    const previousUser = queryClient.getQueryData(['users', userId]);

    // 楽観的にキャッシュを更新
    queryClient.setQueryData(['users', userId], (old: User) => ({
      ...old,
      ...newData,
    }));

    return { previousUser };
  },
  onError: (err, newData, context) => {
    // エラー時はロールバック
    queryClient.setQueryData(['users', userId], context?.previousUser);
  },
  onSettled: () => {
    // 完了後はサーバーから再フェッチして同期
    queryClient.invalidateQueries({ queryKey: ['users', userId] });
  },
});

無限スクロール

import { useInfiniteQuery } from '@tanstack/react-query';
import { useIntersectionObserver } from '@/hooks/use-intersection-observer';

function InfinitePostList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: ({ pageParam = 1 }) =>
      fetch(`/api/posts?page=${pageParam}&limit=10`).then((r) => r.json()),
    getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined,
    initialPageParam: 1,
  });

  const { ref } = useIntersectionObserver({
    onChange: (isIntersecting) => {
      if (isIntersecting && hasNextPage) fetchNextPage();
    },
  });

  return (
    <div>
      {data?.pages.map((page) =>
        page.posts.map((post) => <PostCard key={post.id} post={post} />)
      )}
      <div ref={ref}>
        {isFetchingNextPage && <div>読み込み中...</div>}
      </div>
    </div>
  );
}

キャッシュ戦略

オプション デフォルト 説明
staleTime 0ms データが「古い」とみなされるまでの時間
gcTime 5分 未使用キャッシュが削除されるまでの時間
refetchOnWindowFocus true ウィンドウフォーカス時に再フェッチ
refetchInterval false ポーリング間隔

カスタムフックでの整理

大規模アプリでは、クエリロジックをカスタムフックにまとめると管理しやすくなります。

// hooks/use-user.ts
export function useUser(userId: number) {
  return useQuery({
    queryKey: ['users', userId],
    queryFn: () => fetchUser(userId),
    staleTime: 1000 * 60 * 10, // 10分
  });
}

export function useUpdateUser(userId: number) {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (data: Partial<User>) => updateUser(userId, data),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
  });
}

TanStack Routerとの連携

TanStack RouterのloaderとTanStack Queryを組み合わせると、ルートレベルでのデータプリフェッチが可能です。

export const Route = createFileRoute('/users/$userId')({
  loader: ({ context: { queryClient }, params: { userId } }) =>
    queryClient.ensureQueryData({
      queryKey: ['users', userId],
      queryFn: () => fetchUser(Number(userId)),
    }),
  component: UserProfile,
});

まとめ

TanStack Queryを導入することで:

  • ボイラープレートの削減:loading/error状態の手動管理が不要
  • 自動キャッシュ:同じデータの重複リクエストを防止
  • バックグラウンド更新:ユーザーが気づかないうちにデータを最新化
  • 楽観的更新:体感速度の向上

これらが自動的に手に入ります。サーバー状態管理に悩んでいるなら、TanStack Queryは間違いなく試す価値があります。