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は、未使用データがいつガベージ コレクションされるかを制御します- 突然変異は、
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統合、eコマース自動化、AI搭載ビジネスソリューションに関するインサイトを共有しています。
関連記事
Next.js 16 App Router: Production Patterns and Pitfalls
Production-ready Next.js 16 App Router patterns: server components, caching strategies, metadata API, error boundaries, and performance pitfalls to avoid.
Nginx Production Configuration: SSL, Caching, and Security
Nginx production configuration guide: SSL termination, HTTP/2, caching headers, security headers, rate limiting, reverse proxy setup, and Cloudflare integration patterns.
Optimizing AI Agent Costs: Token Usage and Caching
Practical strategies for reducing AI agent operational costs through token optimization, caching, model routing, and usage monitoring. Real savings from production OpenClaw deployments.
Performance & Scalabilityのその他の記事
k6 Load Testing: Stress-Test Your APIs Before Launch
Master k6 load testing for Node.js APIs. Covers virtual user ramp-ups, thresholds, scenarios, HTTP/2, WebSocket testing, Grafana dashboards, and CI integration patterns.
Nginx Production Configuration: SSL, Caching, and Security
Nginx production configuration guide: SSL termination, HTTP/2, caching headers, security headers, rate limiting, reverse proxy setup, and Cloudflare integration patterns.
Odoo Performance Tuning: PostgreSQL and Server Optimization
Expert guide to Odoo 19 performance tuning. Covers PostgreSQL configuration, indexing, query optimization, Nginx caching, and server sizing for enterprise deployments.
Odoo vs Acumatica: Cloud ERP for Growing Businesses
Odoo vs Acumatica compared for 2026: unique pricing models, scalability, manufacturing depth, and which cloud ERP fits your growth trajectory.
Testing and Monitoring AI Agents in Production
A complete guide to testing and monitoring AI agents in production environments. Covers evaluation frameworks, observability, drift detection, and incident response for OpenClaw deployments.
Compliance Monitoring Agents with OpenClaw
Deploy OpenClaw AI agents for continuous compliance monitoring. Automate regulatory checks, policy enforcement, audit trail generation, and compliance reporting.