TanStack Query v5 実践パターン集 — データフェッチの最適解
TanStack Query v5 概要
TanStack Query(旧React Query)は、Reactアプリケーションでのデータフェッチ、キャッシュ、同期を効率化するライブラリです。v5では、よりシンプルなAPIと強力な型推論が追加されました。
v4からの主な変更点
- 型推論の改善: より強力なTypeScript型推論
- Suspense統合の強化: React 18 Suspenseとのネイティブ統合
- 永続化の改善: より柔軟なキャッシュ永続化
- 開発者ツールの進化: DevToolsの性能向上
npm install @tanstack/react-query@latest
基本セットアップ
// app/providers.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1分
retry: 1,
refetchOnWindowFocus: false,
},
},
});
export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
実践パターン1: 型安全なAPI定義
// lib/api/users.ts
import { z } from "zod";
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
avatar: z.string().url().optional(),
});
type User = z.infer<typeof UserSchema>;
export async function fetchUser(userId: number): Promise<User> {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error(`Failed to fetch user: ${response.status}`);
}
const data = await response.json();
return UserSchema.parse(data); // ランタイム型検証
}
export async function fetchUsers(): Promise<User[]> {
const response = await fetch('/api/users');
if (!response.ok) {
throw new Error('Failed to fetch users');
}
const data = await response.json();
return z.array(UserSchema).parse(data);
}
実践パターン2: Suspenseを使った宣言的UI
// components/UserProfile.tsx
import { useSuspenseQuery } from '@tanstack/react-query';
import { fetchUser } from '@/lib/api/users';
export function UserProfile({ userId }: { userId: number }) {
const { data: user } = useSuspenseQuery({
queryKey: ['users', userId],
queryFn: () => fetchUser(userId),
});
return (
<div className="profile">
<img src={user.avatar} alt={user.name} />
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
// app/users/[id]/page.tsx
import { Suspense } from 'react';
import { UserProfile } from '@/components/UserProfile';
export default function UserPage({ params }: { params: { id: string } }) {
const userId = parseInt(params.id);
return (
<div>
<h1>User Profile</h1>
<Suspense fallback={<UserProfileSkeleton />}>
<UserProfile userId={userId} />
</Suspense>
</div>
);
}
実践パターン3: Optimistic Updates(楽観的更新)
// hooks/useUpdateUser.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { User } from '@/lib/api/users';
export function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ userId, data }: { userId: number; data: Partial<User> }) => {
const response = await fetch(`/api/users/${userId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error('Failed to update user');
}
return response.json();
},
onMutate: async ({ userId, data }) => {
// 進行中のクエリをキャンセル
await queryClient.cancelQueries({ queryKey: ['users', userId] });
// 以前の値を保存
const previousUser = queryClient.getQueryData<User>(['users', userId]);
// 楽観的更新
queryClient.setQueryData<User>(['users', userId], (old) => ({
...old!,
...data,
}));
return { previousUser };
},
onError: (err, { userId }, context) => {
// エラー時にロールバック
queryClient.setQueryData(['users', userId], context?.previousUser);
},
onSettled: (data, error, { userId }) => {
// 成功・失敗に関わらず再フェッチ
queryClient.invalidateQueries({ queryKey: ['users', userId] });
},
});
}
// 使用例
function UserEditForm({ user }: { user: User }) {
const updateUser = useUpdateUser();
const handleSubmit = (data: Partial<User>) => {
updateUser.mutate({ userId: user.id, data });
};
return (
<form onSubmit={(e) => { e.preventDefault(); handleSubmit({ name: 'New Name' }); }}>
{/* フォームコンポーネント */}
{updateUser.isPending && <p>Updating...</p>}
{updateUser.isError && <p>Error: {updateUser.error.message}</p>}
</form>
);
}
実践パターン4: Infinite Queries(無限スクロール)
// hooks/useInfiniteUsers.ts
import { useInfiniteQuery } from '@tanstack/react-query';
interface UsersResponse {
users: User[];
nextCursor: number | null;
}
async function fetchUsersPage(cursor: number = 0): Promise<UsersResponse> {
const response = await fetch(`/api/users?cursor=${cursor}&limit=20`);
return response.json();
}
export function useInfiniteUsers() {
return useInfiniteQuery({
queryKey: ['users', 'infinite'],
queryFn: ({ pageParam }) => fetchUsersPage(pageParam),
initialPageParam: 0,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});
}
// components/UserList.tsx
import { useInfiniteUsers } from '@/hooks/useInfiniteUsers';
import { useInView } from 'react-intersection-observer';
import { useEffect } from 'react';
export function InfiniteUserList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteUsers();
const { ref, inView } = useInView();
useEffect(() => {
if (inView && hasNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, fetchNextPage]);
return (
<div>
{data?.pages.map((page, i) => (
<div key={i}>
{page.users.map((user) => (
<UserCard key={user.id} user={user} />
))}
</div>
))}
<div ref={ref}>
{isFetchingNextPage && <p>Loading more...</p>}
</div>
</div>
);
}
実践パターン5: SSR対応(Next.js App Router)
// app/users/page.tsx
import { HydrationBoundary, QueryClient, dehydrate } from '@tanstack/react-query';
import { fetchUsers } from '@/lib/api/users';
import { UserList } from '@/components/UserList';
export default async function UsersPage() {
const queryClient = new QueryClient();
// サーバー側でプリフェッチ
await queryClient.prefetchQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<UserList />
</HydrationBoundary>
);
}
// components/UserList.tsx(クライアントコンポーネント)
'use client';
import { useQuery } from '@tanstack/react-query';
import { fetchUsers } from '@/lib/api/users';
export function UserList() {
const { data: users } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
return (
<ul>
{users?.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
実践パターン6: キャッシュ永続化
// lib/queryClient.ts
import { QueryClient } from '@tanstack/react-query';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 1000 * 60 * 60 * 24, // 24時間
},
},
});
const persister = createSyncStoragePersister({
storage: window.localStorage,
});
export function PersistedQueryProvider({ children }: { children: React.ReactNode }) {
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister }}
>
{children}
</PersistQueryClientProvider>
);
}
まとめ
TanStack Query v5は、以下のような場面で真価を発揮します。
最適なケース:
- SPAやNext.js App Routerでの複雑なデータフェッチ
- リアルタイム性が求められるダッシュボード
- Optimistic Updatesが必要なフォーム
- 無限スクロールやページネーション
注意点:
- 静的サイトには過剰(Astroなどではfetchで十分)
- サーバーコンポーネント中心の設計では不要な場面も
- キャッシュ戦略の理解が必要
v5の型推論とSuspense統合により、Reactのデータフェッチは新しい段階に入りました。この記事のパターンを参考に、あなたのプロジェクトに最適な実装を見つけてください。