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は、未使用データがいつガベージ コレクションされるかを制御します- 突然変異は、
onSuccessのqueryClient.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 は、ユーザーのアクションがサーバー データに影響を与えるクライアント側の対話性を実現します。
staleTime と gcTime の違いは何ですか?
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 アプリケーションを構築する方法をご覧ください。
執筆者
ECOSIRE Research and Development Teamテクニカルライティング
ECOSIRE のテクニカルライティングチームは、Odoo ERP、Shopify による EC、AI エージェント、Power BI による分析、GoHighLevel による自動化、そしてエンタープライズソフトウェアのベストプラクティスを扱っています。私たちのガイドは、企業が十分な情報に基づいてテクノロジーを判断できるよう支援します。
関連記事
Next.js 16 アプリルーター: 制作パターンと落とし穴
本番環境に対応した Next.js 16 の App Router パターン: サーバー コンポーネント、キャッシュ戦略、メタデータ API、エラー境界、回避すべきパフォーマンスの落とし穴。
Nginx 実稼働構成: SSL、キャッシュ、およびセキュリティ
Nginx 実稼働構成ガイド: SSL 終端、HTTP/2、キャッシュヘッダー、セキュリティヘッダー、レート制限、リバースプロキシセットアップ、Cloudflare 統合パターン。
AI エージェントのコストの最適化: トークンの使用とキャッシュ
トークンの最適化、キャッシュ、モデルのルーティング、使用状況の監視を通じて AI エージェントの運用コストを削減するための実践的な戦略。本番環境の OpenClaw 導入による実質的な節約。
パフォーマンスとスケーラビリティのその他の記事
Webhook のデバッグと監視: 完全なトラブルシューティング ガイド
障害パターン、デバッグ ツール、再試行戦略、監視ダッシュボード、セキュリティのベスト プラクティスを網羅したこの完全なガイドを利用して、Webhook デバッグをマスターしてください。
k6 負荷テスト: 起動前に API のストレス テストを行う
Node.js API の k6 負荷テストをマスターします。仮想ユーザーの増加、しきい値、シナリオ、HTTP/2、WebSocket テスト、Grafana ダッシュボード、CI 統合パターンをカバーします。
Nginx 実稼働構成: SSL、キャッシュ、およびセキュリティ
Nginx 実稼働構成ガイド: SSL 終端、HTTP/2、キャッシュヘッダー、セキュリティヘッダー、レート制限、リバースプロキシセットアップ、Cloudflare 統合パターン。
Odoo パフォーマンス チューニング: PostgreSQL とサーバーの最適化
Odoo 19 のパフォーマンス チューニングに関する専門ガイド。 PostgreSQL の構成、インデックス作成、クエリの最適化、Nginx キャッシュ、エンタープライズ展開のためのサーバーのサイジングについて説明します。
本番環境での AI エージェントのテストと監視
実稼働環境で AI エージェントをテストおよび監視するための完全なガイド。 OpenClaw 導入の評価フレームワーク、可観測性、ドリフト検出、インシデント対応について説明します。
OpenClaw を使用したコンプライアンス監視エージェント
継続的なコンプライアンス監視のために OpenClaw AI エージェントを導入します。規制チェック、ポリシーの適用、監査証跡の生成、コンプライアンスレポートを自動化します。