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 TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
関連記事
Shopify App Bridge 4 チュートリアル: 2026 年の組み込みアプリの構築
App Bridge 4 を使用して Shopify 埋め込み管理アプリを構築します: セッション トークン、トークン交換、ナビゲーション、モーダル、リソース ピッカー、Polaris React 13 セットアップ。
React 19 サーバー コンポーネント移行ガイド 2026: 実際の運用パターン
実戦テスト済みの React 19 サーバー コンポーネント移行ガイド: データのフェッチ、ストリーミング、サスペンス トラップ、クライアント/サーバーの境界、落とし穴、および測定されたパフォーマンスの勝利。
Next.js 16 アプリルーター: 制作パターンと落とし穴
本番環境に対応した Next.js 16 の App Router パターン: サーバー コンポーネント、キャッシュ戦略、メタデータ API、エラー境界、回避すべきパフォーマンスの落とし穴。
Performance & Scalabilityのその他の記事
Odoo 19 HR: スキル マトリックス、キャリア プラン、パフォーマンス サイクル
Odoo 19 HR アップグレード: ネイティブ スキル マトリックス、キャリア パス計画、パフォーマンス レビュー サイクル、9 ボックス グリッド、後継者計画、HRIS 統合。
Odoo 19 パフォーマンス ベンチマーク: PostgreSQL 17 のチューニング数値
実際の Odoo 19 パフォーマンス ベンチマーク: Web クライアント速度、ORM スループット、PG17 チューニング設定、接続プーリング、ワーカー数、スケーリングしきい値。
OpenClaw のコスト最適化と大規模なトークン効率
OpenClaw トークン コストの最適化: プロンプト キャッシュ、モデル ルーティング、応答キャッシュ、バッチ API、実稼働エージェントのテナントごとのコスト ガードレール。
1,000 万行を超えるテーブルの Power BI 増分更新
1,000 万行以上のテーブル用の Power BI 増分更新プレイブック: パーティション設計、RangeStart/RangeEnd、更新ポリシー、クエリの折りたたみ、DirectQuery ハイブリッド。
Webhook のデバッグと監視: 完全なトラブルシューティング ガイド
障害パターン、デバッグ ツール、再試行戦略、監視ダッシュボード、セキュリティのベスト プラクティスを網羅したこの完全なガイドを利用して、Webhook デバッグをマスターしてください。
k6 負荷テスト: 起動前に API のストレス テストを行う
Node.js API の k6 負荷テストをマスターします。仮想ユーザーの増加、しきい値、シナリオ、HTTP/2、WebSocket テスト、Grafana ダッシュボード、CI 統合パターンをカバーします。