React Query (TanStack): Data Fetching Patterns

Master TanStack Query v5 data fetching patterns: queries, mutations, optimistic updates, infinite scroll, prefetching, cache invalidation, and integration with Next.js App Router.

E
ECOSIRE Research and Development Team
|2026年3月19日7 分で読める1.6k 語数|

Performance & Scalabilityシリーズの一部

完全ガイドを読む

React Query (TanStack): データ取得パターン

React でのデータ フェッチは、ロード状態、エラー状態、バックグラウンドでの再フェッチ、変更後のキャッシュの無効化、オプティミスティック更新、無限スクロール、プリフェッチ、重複排除など、驚くほど広い表面積を持っています。このロジックを useEffect + useState を使用して手動で記述すると、競合状態、メモリ リーク、一貫性のない UI 状態が発生します。 TanStack Query (旧 React Query) v5 は、クリーンで構成可能な API でこれらすべてを解決します。

このガイドでは、基本的なクエリから複雑な変更フロー、オプティミスティック更新、無限スクロール、Next.js App Router の統合まで、実稼働 React アプリケーション向けの TanStack Query v5 パターンについて説明します。すべてのパターンには TypeScript とエラー処理が含まれます。

重要なポイント

  • TanStack Query はサーバー状態ライブラリです。クライアント専用の UI 状態には使用しないでください (そのためには Zustand または useState を使用してください)
  • クエリ キーはキャッシュ アドレスです - クエリ結果に影響を与えるすべての変数が含まれます
  • staleTime は、バックグラウンドでの再フェッチがいつトリガーされるかを制御します。 gcTime は、未使用データがいつガベージ コレクションされるかを制御します
  • 突然変異は、onSuccessqueryClient.invalidateQueries() を介して関連するクエリを無効にする必要があります
  • 楽観的な更新により体感的なパフォーマンスが向上しますが、onError のロールバック ロジックが必要です
  • ページ分割されたリストには useInfiniteQuery を使用します。 useQuery + マニュアルページの状態を決して組み合わせないでください
  • ホバー時にプリフェッチして瞬時にナビゲーションを実現: queryClient.prefetchQuery()
  • Next.js App Router で、HydrationBoundary を使用してサーバーから取得したデータをクライアントにデハイドレートします

セットアップと構成

pnpm add @tanstack/react-query @tanstack/react-query-devtools
// src/providers/query-provider.tsx
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState } from 'react';

export function QueryProvider({ children }: { children: React.ReactNode }) {
  // Create QueryClient inside component to avoid shared state between requests (SSR safety)
  const [queryClient] = useState(() =>
    new QueryClient({
      defaultOptions: {
        queries: {
          staleTime:            60 * 1000, // 1 minute — data is fresh for 1 minute
          gcTime:           5 * 60 * 1000, // 5 minutes — keep unused cache for 5 minutes
          retry:                        2, // Retry failed requests twice
          retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30_000), // Exponential backoff
          refetchOnWindowFocus:      true, // Refetch when user returns to tab
          refetchOnReconnect:        true, // Refetch after network reconnect
        },
        mutations: {
          retry: 0, // Do not retry mutations (non-idempotent)
        },
      },
    })
  );

  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}
// src/app/[locale]/layout.tsx
import { QueryProvider } from '@/providers/query-provider';

export default function LocaleLayout({ children }: { children: React.ReactNode }) {
  return (
    <QueryProvider>
      {children}
    </QueryProvider>
  );
}

クエリ キー — 財団

クエリ キーはデータのキャッシュ アドレスです。クエリ結果に影響を与えるすべての変数がキーに含まれている必要があります。

// src/lib/query-keys.ts
// Centralize query keys to avoid typos and enable easy invalidation
export const queryKeys = {
  contacts: {
    all:          () => ['contacts'] as const,
    lists:        () => [...queryKeys.contacts.all(), 'list'] as const,
    list:         (filters: ContactFilters) =>
                    [...queryKeys.contacts.lists(), filters] as const,
    details:      () => [...queryKeys.contacts.all(), 'detail'] as const,
    detail:       (id: string) =>
                    [...queryKeys.contacts.details(), id] as const,
  },
  orders: {
    all:    () => ['orders'] as const,
    list:   (orgId: string, page: number) => ['orders', orgId, page] as const,
    detail: (id: string) => ['orders', id] as const,
  },
} as const;

// Usage in queries and invalidations:
// useQuery({ queryKey: queryKeys.contacts.list({ page: 1, limit: 20 }) })
// queryClient.invalidateQueries({ queryKey: queryKeys.contacts.lists() })
// — invalidates ALL list queries regardless of filter parameters

基本的なクエリ

// src/hooks/use-contacts.ts
import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api';
import { queryKeys } from '@/lib/query-keys';

interface ContactFilters {
  page: number;
  limit: number;
  search?: string;
  status?: string;
}

interface ContactsResponse {
  data: Contact[];
  total: number;
  page: number;
  limit: number;
}

export function useContacts(filters: ContactFilters) {
  return useQuery({
    queryKey: queryKeys.contacts.list(filters),
    queryFn: async () => {
      const params = new URLSearchParams({
        page: String(filters.page),
        limit: String(filters.limit),
        ...(filters.search && { search: filters.search }),
        ...(filters.status && { status: filters.status }),
      });
      return apiFetch<ContactsResponse>(`/contacts?${params}`);
    },
    placeholderData: (previousData) => previousData, // Keep previous page data while fetching
    staleTime: 30_000, // Contacts data: 30 second stale time
  });
}

export function useContact(id: string) {
  return useQuery({
    queryKey: queryKeys.contacts.detail(id),
    queryFn: () => apiFetch<Contact>(`/contacts/${id}`),
    enabled: !!id, // Only fetch if id is provided
  });
}
// Usage in a component
'use client';

export default function ContactsPage() {
  const [page, setPage] = useState(1);
  const [search, setSearch] = useState('');

  const { data, isLoading, isError, error, isFetching } = useContacts({
    page,
    limit: 20,
    search: search || undefined,
  });

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

  return (
    <div>
      {isFetching && <RefetchIndicator />} {/* Shows during background refetch */}
      <ContactsTable data={data.data} />
      <Pagination
        page={page}
        total={data.total}
        limit={20}
        onChange={setPage}
      />
    </div>
  );
}

キャッシュの無効化を伴う突然変異

// src/hooks/use-contact-mutations.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api';
import { queryKeys } from '@/lib/query-keys';
import { toast } from '@/components/ui/toast';

export function useCreateContact() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (data: CreateContactDto) =>
      apiFetch<Contact>('/contacts', {
        method: 'POST',
        body: JSON.stringify(data),
      }),

    onSuccess: (newContact) => {
      // Invalidate all contact list queries — they are now stale
      queryClient.invalidateQueries({
        queryKey: queryKeys.contacts.lists(),
      });

      // Seed the detail cache immediately — no need to refetch
      queryClient.setQueryData(
        queryKeys.contacts.detail(newContact.id),
        newContact
      );

      toast({ title: 'Contact created', variant: 'success' });
    },

    onError: (error: Error) => {
      toast({ title: 'Failed to create contact', description: error.message, variant: 'destructive' });
    },
  });
}

export function useDeleteContact() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (id: string) =>
      apiFetch(`/contacts/${id}`, { method: 'DELETE' }),

    onSuccess: (_data, deletedId) => {
      // Remove from all list queries
      queryClient.invalidateQueries({ queryKey: queryKeys.contacts.lists() });
      // Remove detail cache
      queryClient.removeQueries({ queryKey: queryKeys.contacts.detail(deletedId) });
      toast({ title: 'Contact deleted', variant: 'success' });
    },
  });
}

楽観的なアップデート

楽観的な更新では、サーバーが確認する前に UI を更新することで、突然変異が即座に感じられるようにします。

export function useUpdateContact() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: ({ id, data }: { id: string; data: UpdateContactDto }) =>
      apiFetch<Contact>(`/contacts/${id}`, {
        method: 'PATCH',
        body: JSON.stringify(data),
      }),

    // Called immediately before mutationFn
    onMutate: async ({ id, data }) => {
      // Cancel any in-flight refetches for this contact
      await queryClient.cancelQueries({ queryKey: queryKeys.contacts.detail(id) });

      // Snapshot the previous value for rollback
      const previousContact = queryClient.getQueryData<Contact>(
        queryKeys.contacts.detail(id)
      );

      // Optimistically update the UI
      queryClient.setQueryData<Contact>(
        queryKeys.contacts.detail(id),
        (old) => old ? { ...old, ...data } : old
      );

      return { previousContact }; // Passed to onError context
    },

    onError: (_error, { id }, context) => {
      // Roll back to the snapshot on error
      if (context?.previousContact) {
        queryClient.setQueryData(
          queryKeys.contacts.detail(id),
          context.previousContact
        );
      }
      toast({ title: 'Update failed', variant: 'destructive' });
    },

    onSettled: (_data, _error, { id }) => {
      // Always refetch after success or error to sync with server truth
      queryClient.invalidateQueries({ queryKey: queryKeys.contacts.detail(id) });
    },
  });
}

無限スクロール / ページネーション

// src/hooks/use-infinite-contacts.ts
import { useInfiniteQuery } from '@tanstack/react-query';

export function useInfiniteContacts(search?: string) {
  return useInfiniteQuery({
    queryKey: ['contacts', 'infinite', search],
    queryFn: ({ pageParam = 1 }) =>
      apiFetch<ContactsResponse>(`/contacts?page=${pageParam}&limit=20${search ? `&search=${search}` : ''}`),

    initialPageParam: 1,
    getNextPageParam: (lastPage) => {
      const nextPage = lastPage.page + 1;
      const maxPage = Math.ceil(lastPage.total / lastPage.limit);
      return nextPage <= maxPage ? nextPage : undefined;
    },

    staleTime: 30_000,
  });
}
// Infinite scroll component
'use client';

import { useInfiniteContacts } from '@/hooks/use-infinite-contacts';
import { useInView } from 'react-intersection-observer';
import { useEffect } from 'react';

export function InfiniteContactsList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
  } = useInfiniteContacts();

  const { ref, inView } = useInView({ threshold: 0.5 });

  // Fetch next page when the sentinel element enters the viewport
  useEffect(() => {
    if (inView && hasNextPage && !isFetchingNextPage) {
      fetchNextPage();
    }
  }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);

  if (isLoading) return <ContactsSkeleton />;

  const contacts = data?.pages.flatMap((page) => page.data) ?? [];

  return (
    <div>
      {contacts.map((contact) => (
        <ContactCard key={contact.id} contact={contact} />
      ))}

      {/* Sentinel element — triggers next page load when visible */}
      <div ref={ref} className="h-4">
        {isFetchingNextPage && <LoadingSpinner />}
      </div>
    </div>
  );
}

インスタントナビゲーションのためのプリフェッチ

// Prefetch on hover — data is ready before the user clicks
'use client';

import { useQueryClient } from '@tanstack/react-query';
import { Link } from '@/i18n/navigation';
import { queryKeys } from '@/lib/query-keys';
import { apiFetch } from '@/lib/api';

export function ContactRow({ contact }: { contact: Contact }) {
  const queryClient = useQueryClient();

  const handleMouseEnter = () => {
    queryClient.prefetchQuery({
      queryKey: queryKeys.contacts.detail(contact.id),
      queryFn: () => apiFetch<Contact>(`/contacts/${contact.id}`),
      staleTime: 60_000, // Use cached if less than 1 minute old
    });
  };

  return (
    <tr onMouseEnter={handleMouseEnter}>
      <td>
        <Link href={`/dashboard/contacts/${contact.id}`}>{contact.name}</Link>
      </td>
      <td>{contact.email}</td>
    </tr>
  );
}

Next.js アプリルーター SSR + TanStack クエリ

// src/app/[locale]/dashboard/contacts/page.tsx (Server Component)
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import { queryKeys } from '@/lib/query-keys';
import { getServerContacts } from '@/lib/server-api';
import { ContactsClient } from './contacts-client';

export default async function ContactsPage({ params }: Props) {
  const { locale } = await params;
  const queryClient = new QueryClient();

  // Prefetch on the server — data is embedded in the HTML
  await queryClient.prefetchQuery({
    queryKey: queryKeys.contacts.list({ page: 1, limit: 20 }),
    queryFn: () => getServerContacts({ page: 1, limit: 20 }),
  });

  return (
    // HydrationBoundary serializes the server QueryClient state
    // into the HTML, which the client QueryClient then picks up
    <HydrationBoundary state={dehydrate(queryClient)}>
      <ContactsClient />
    </HydrationBoundary>
  );
}
// src/app/[locale]/dashboard/contacts/contacts-client.tsx ('use client')
'use client';

import { useContacts } from '@/hooks/use-contacts';

export function ContactsClient() {
  const { data } = useContacts({ page: 1, limit: 20 });
  // data is immediately available from the server-dehydrated state
  // No loading state on first render — SSR data is used instantly

  return <ContactsTable data={data?.data ?? []} />;
}

よくある質問

TanStack クエリ、SWR、プレーンフェッチのどのような場合に使用すべきですか?

TanStack Query は、変更後のキャッシュの無効化、オプティミスティック更新、無限スクロール、プリフェッチ、バックグラウンドの再フェッチ、またはリクエストの重複排除が必要な場合に使用します。小規模なバンドルが必要で、単純な使用例のみが必要な場合は、SWR を使用してください。 React のネイティブ データ キャッシュ (cache() 関数と revalidatePath() パターン) が必要な場合は、Next.js サーバー コンポーネントでプレーン fetch を使用します。 TanStack Query は、ユーザーのアクションがサーバー データに影響を与えるクライアント側の対話性を実現します。

staleTimegcTime の違いは何ですか?

staleTime は、データが新しいとみなされる期間です。この期間中はバックグラウンドでの再取得は行われず、キャッシュされたデータがすぐに返されます。 staleTime の有効期限が切れると、データは「古く」なり、次回の使用時にバックグラウンドで再フェッチされます。 gcTime (以前の cacheTime) は、ガベージ コレクションの前に未使用 (アクティブなサブスクライバーなし) データがメモリ内に留まる期間です。データの変化の速さに基づいて staleTime を設定します。瞬時に離れたり戻ったりするのに必要な時間に基づいて gcTime を設定します。

認証エラー (401) をグローバルに処理するにはどうすればよいですか?

QueryClient のデフォルト オプションにグローバル エラー ハンドラーを追加します。 401 では、ログインにリダイレクトし、クエリ キャッシュをクリアします。 TanStack クエリ v5: defaultOptions: { queries: { throwOnError: (error) => { if (error.status === 401) { router.push('/auth/login'); queryClient.clear(); return false; } return true; } } }。あるいは、logout() 関数を呼び出して、apiFetch ヘルパーで 401 を処理します。

プロップドリルを行わずに兄弟コンポーネント間でクエリ状態を共有するにはどうすればよいですか?

同じクエリ キーで useQuery を呼び出すコンポーネントはすべて、同じキャッシュ データを共有します。TanStack Query はリクエストを自動的に重複排除します。同じフィルターを使用して useContacts({ page: 1, limit: 20 }) を呼び出す 2 つの ContactsTable コンポーネントと ContactCount バッジはすべて、単一のキャッシュされたリクエストから読み取ります。小道具の穴あけやコンテキストは必要ありません。

クエリ キーにはユーザーのセッション ID または組織 ID を含めるべきですか?

はい - データのスコープがユーザーまたは組織に設定されている場合は、クエリ キーにスコープ識別子を含めます。これにより、あるユーザーのデータが別のユーザーに表示されるのを防ぎます (マルチテナント アプリでは重要です)。また、ログアウト時にクエリ キャッシュ全体をクリアします: queryClient.clear() または queryClient.removeQueries() — そうしないと、前のセッションの古いデータが次のユーザーのために一時的にフラッシュされる可能性があります。


次のステップ

TanStack Query は、React データのフェッチを状態管理の問題から宣言型のキャッシュ対応システムに変換します。楽観的なアップデート、プリフェッチ、SSR ハイドレーションにより、接続が遅い場合でも、即時に感じられるエクスペリエンスがユーザーに提供されます。

ECOSIRE は、すべてのサーバー状態の TanStack Query を使用して Next.js 管理パネルとカスタマー ポータルを構築し、クライアントのみの UI 状態の Zustand とデータ量の多いビューの TanStack Table を組み合わせます。 弊社のフロントエンド エンジニアリング サービスをご覧ください して、パフォーマンスに優れ、本番環境に対応した React アプリケーションを構築する方法をご覧ください。

E

執筆者

ECOSIRE Research and Development Team

ECOSIREでエンタープライズグレードのデジタル製品を開発。Odoo統合、eコマース自動化、AI搭載ビジネスソリューションに関するインサイトを共有しています。

WhatsAppでチャット