TanStack Query (React Query) 完全ガイド 2026 - サーバー状態管理の決定版
TanStack Query(旧React Query)は、サーバー状態管理のデファクトスタンダードです。複雑なデータフェッチングロジックをシンプルに記述でき、キャッシング、リトライ、楽観的更新などを自動で処理してくれます。
TanStack Queryとは?
TanStack Queryは、サーバーからのデータ取得・更新・キャッシュを管理するライブラリです。
なぜTanStack Queryが必要なのか?
従来のuseEffectとuseStateによるデータフェッチングには多くの問題があります。
問題のあるコード:
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
})
.catch(err => {
setError(err);
setLoading(false);
});
}, [userId]);
if (loading) return <div>読み込み中...</div>;
if (error) return <div>エラー: {error.message}</div>;
return <div>{user.name}</div>;
}
問題点:
- キャッシュなし(同じデータを何度もフェッチ)
- リトライなし
- バックグラウンド更新なし
- 楽観的更新なし
- コードが冗長
TanStack Queryを使った改善版:
function UserProfile({ userId }: { userId: string }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetch(`/api/users/${userId}`).then(res => res.json()),
});
if (isLoading) return <div>読み込み中...</div>;
if (error) return <div>エラー: {error.message}</div>;
return <div>{data.name}</div>;
}
これだけで、キャッシング、自動リトライ、バックグラウンド更新が全て有効になります。
セットアップ
インストール
npm install @tanstack/react-query
npm install -D @tanstack/eslint-plugin-query
プロバイダーの設定
app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useState } from 'react';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1分
gcTime: 5 * 60 * 1000, // 5分(旧cacheTime)
retry: 1,
refetchOnWindowFocus: false,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
app/layout.tsx
import { Providers } from './providers';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ja">
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
useQuery - データの取得
基本的な使い方
import { useQuery } from '@tanstack/react-query';
function Posts() {
const { data, isLoading, error } = useQuery({
queryKey: ['posts'],
queryFn: async () => {
const response = await fetch('/api/posts');
if (!response.ok) throw new Error('Failed to fetch');
return response.json();
},
});
if (isLoading) return <div>読み込み中...</div>;
if (error) return <div>エラー: {error.message}</div>;
return (
<ul>
{data.map((post: any) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
TypeScriptで型安全に
interface Post {
id: number;
title: string;
content: string;
}
function Posts() {
const { data, isLoading } = useQuery<Post[]>({
queryKey: ['posts'],
queryFn: async () => {
const response = await fetch('/api/posts');
return response.json();
},
});
// dataはPost[] | undefinedとして扱われる
return (
<div>
{data?.map((post) => (
<div key={post.id}>{post.title}</div>
))}
</div>
);
}
パラメータ付きクエリ
function PostDetail({ postId }: { postId: number }) {
const { data } = useQuery({
queryKey: ['post', postId], // queryKeyに含める
queryFn: async () => {
const response = await fetch(`/api/posts/${postId}`);
return response.json();
},
enabled: !!postId, // postIdがある時だけ実行
});
return <div>{data?.title}</div>;
}
依存クエリ
前のクエリの結果を使って次のクエリを実行します。
function UserPosts({ userId }: { userId: number }) {
// まずユーザー情報を取得
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
// ユーザー情報が取得できたら投稿を取得
const { data: posts } = useQuery({
queryKey: ['posts', user?.id],
queryFn: () => fetchUserPosts(user!.id),
enabled: !!user, // userが存在する時だけ実行
});
return <div>{/* ... */}</div>;
}
useMutation - データの更新
基本的な使い方
import { useMutation, useQueryClient } from '@tanstack/react-query';
function CreatePost() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: async (newPost: { title: string; content: string }) => {
const response = await fetch('/api/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newPost),
});
return response.json();
},
onSuccess: () => {
// 成功したらpostsキャッシュを無効化して再取得
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
mutation.mutate({
title: formData.get('title') as string,
content: formData.get('content') as string,
});
};
return (
<form onSubmit={handleSubmit}>
<input name="title" placeholder="タイトル" required />
<textarea name="content" placeholder="本文" required />
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? '送信中...' : '投稿'}
</button>
{mutation.isError && <div>エラー: {mutation.error.message}</div>}
{mutation.isSuccess && <div>投稿しました!</div>}
</form>
);
}
楽観的更新
UIを即座に更新し、サーバーへのリクエストは後で実行します。
function TodoItem({ todo }: { todo: Todo }) {
const queryClient = useQueryClient();
const toggleMutation = useMutation({
mutationFn: async (id: number) => {
await fetch(`/api/todos/${id}/toggle`, { method: 'PATCH' });
},
onMutate: async (todoId) => {
// 進行中のクエリをキャンセル
await queryClient.cancelQueries({ queryKey: ['todos'] });
// 前の値を保存
const previousTodos = queryClient.getQueryData(['todos']);
// 楽観的にUIを更新
queryClient.setQueryData(['todos'], (old: Todo[] | undefined) =>
old?.map((t) =>
t.id === todoId ? { ...t, completed: !t.completed } : t
)
);
// ロールバック用に前の値を返す
return { previousTodos };
},
onError: (err, todoId, context) => {
// エラー時はロールバック
queryClient.setQueryData(['todos'], context?.previousTodos);
},
onSettled: () => {
// 成功/失敗に関わらず再取得
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
return (
<div>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleMutation.mutate(todo.id)}
/>
<span>{todo.title}</span>
</div>
);
}
無限スクロール(useInfiniteQuery)
import { useInfiniteQuery } from '@tanstack/react-query';
interface PostsResponse {
posts: Post[];
nextCursor: number | null;
}
function InfinitePosts() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: async ({ pageParam = 0 }) => {
const response = await fetch(`/api/posts?cursor=${pageParam}`);
return response.json() as Promise<PostsResponse>;
},
getNextPageParam: (lastPage) => lastPage.nextCursor,
initialPageParam: 0,
});
return (
<div>
{data?.pages.map((page, i) => (
<div key={i}>
{page.posts.map((post) => (
<div key={post.id}>{post.title}</div>
))}
</div>
))}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage
? '読み込み中...'
: hasNextPage
? 'もっと見る'
: 'これで全部です'}
</button>
</div>
);
}
Intersection Observerで自動読み込み
import { useEffect, useRef } from 'react';
function InfinitePosts() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
// ... 同じ設定
});
const observerTarget = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ threshold: 1 }
);
const currentTarget = observerTarget.current;
if (currentTarget) {
observer.observe(currentTarget);
}
return () => {
if (currentTarget) {
observer.unobserve(currentTarget);
}
};
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
return (
<div>
{data?.pages.map((page) => (
// ... ポストを表示
))}
<div ref={observerTarget} />
{isFetchingNextPage && <div>読み込み中...</div>}
</div>
);
}
プリフェッチ
リンクにホバーした時などに事前にデータを取得しておくことで、UXを向上できます。
import { useQueryClient } from '@tanstack/react-query';
function PostLink({ postId }: { postId: number }) {
const queryClient = useQueryClient();
const prefetchPost = () => {
queryClient.prefetchQuery({
queryKey: ['post', postId],
queryFn: () => fetchPost(postId),
});
};
return (
<a
href={`/posts/${postId}`}
onMouseEnter={prefetchPost} // ホバー時にプリフェッチ
>
投稿を見る
</a>
);
}
React Query DevTools
開発中はDevToolsを使って、クエリの状態やキャッシュを視覚的に確認できます。
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
画面右下にアイコンが表示され、クリックすると詳細が確認できます。
ベストプラクティス
1. queryKeyの命名規則
// 良い例: 階層的で明確
['users'] // 全ユーザー
['users', userId] // 特定ユーザー
['users', userId, 'posts'] // ユーザーの投稿
['users', userId, 'posts', { status: 'published' }] // フィルター付き
// 悪い例: 一貫性がない
['user-123']
['getUserPosts']
2. カスタムフックで再利用
// hooks/usePosts.ts
export function usePosts() {
return useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
});
}
export function usePost(postId: number) {
return useQuery({
queryKey: ['post', postId],
queryFn: () => fetchPost(postId),
enabled: !!postId,
});
}
export function useCreatePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createPost,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
}
3. エラーハンドリング
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error: any) => {
// 404はリトライしない
if (error?.status === 404) return false;
// それ以外は3回までリトライ
return failureCount < 3;
},
},
},
});
まとめ
TanStack Query v5は、サーバー状態管理における最強のツールです。
主要な機能:
- 自動キャッシング・バックグラウンド更新
- 楽観的更新
- 無限スクロール
- プリフェッチ
- TypeScript完全対応
- DevToolsで可視化
公式ドキュメント: https://tanstack.com/query/latest
useEffectとfetchを手動で管理する時代は終わりました。TanStack Queryで、よりシンプルで保守性の高いコードを書きましょう。