tanstack-query
React アプリケーションにおける非同期サーバー状態管理のための TanStack Query(React Query)を扱うスキルで、自動キャッシュ・バックグラウンド再取得・楽観的更新・ページネーションが必要な場面で活躍します。
description の原文を見る
TanStack Query (React Query) for asynchronous server-state management with automatic caching, background refetching, optimistic updates, and pagination in React applications.
SKILL.md 本文
TanStack Query (React Query) スキル
概要
TanStack Query(旧 React Query)は、React 用の強力な非同期状態管理ライブラリであり、サーバー状態の取得、キャッシング、同期、更新を処理します。手動によるデータ取得ボイラープレート不要で、バックグラウンド再取得、楽観的更新、ページネーション、インテリジェントなキャッシュ管理などの組み込み機能を提供します。
いつ使うべきか
以下の場合に TanStack Query を使用してください:
- REST API、GraphQL、tRPC エンドポイントからデータを取得する
- 自動バックグラウンド再取得とキャッシュ無効化が必要
- ポーリングまたは WebSocket データを使用した実時間ダッシュボードを構築している
- 無限スクロールまたはページネーションを実装している
- ミューテーション向けの楽観的 UI 更新が必要
- 複雑なサーバー状態の同期を管理している
- キャッシュ永続化によるオフラインサポートが必要
- 頻繁なデータ更新があるアプリケーションを構築している
TanStack Query が優れている点:
- サーバー状態管理(API データ、外部状態)
- リクエストの重複排除とキャッシング
- Stale-While-Revalidate パターン
- ローディングおよびエラー状態管理
- プリフェッチと先読み
- 並列および依存クエリのオーケストレーション
TanStack Query を避けるべき場合:
- 純粋なクライアント側の状態(Zustand、Jotai、Context を使用)
- フォーム状態管理(React Hook Form、Formik を使用)
- キャッシング不要なシンプルなワンタイム取得
クイックスタート
インストール
npm install @tanstack/react-query
# DevTools(オプションですが推奨)
npm install @tanstack/react-query-devtools
基本的なセットアップ
// 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 minute
refetchOnWindowFocus: false,
},
},
}));
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
最初のクエリ
// components/UserProfile.tsx
import { useQuery } from '@tanstack/react-query';
interface User {
id: number;
name: string;
email: string;
}
function UserProfile({ userId }: { userId: number }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: async () => {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('Failed to fetch user');
return response.json() as Promise<User>;
},
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<h1>{data.name}</h1>
<p>{data.email}</p>
</div>
);
}
最初のミューテーション
// components/CreateUserForm.tsx
import { useMutation, useQueryClient } from '@tanstack/react-query';
function CreateUserForm() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: async (newUser: { name: string; email: string }) => {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newUser),
});
return response.json();
},
onSuccess: () => {
// ユーザーリストを無効化して再取得
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
mutation.mutate({
name: formData.get('name') as string,
email: formData.get('email') as string,
});
};
return (
<form onSubmit={handleSubmit}>
<input name="name" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create User'}
</button>
{mutation.isError && <p>Error: {mutation.error.message}</p>}
</form>
);
}
コア概念
サーバー状態とクライアント状態
サーバー状態の特性:
- リモートに永続化(データベース、API、クラウド)
- 取得/更新に非同期 API が必要
- クライアントと同期していない可能性がある
- 他のユーザー/システムによって更新される可能性がある
- 例:ユーザーデータ、投稿、プロダクト、設定
クライアント状態の特性:
- ローカルに永続化(メモリ、localStorage)
- 同期的にアクセス可能
- クライアントによって完全に制御される
- 例:UI テーマ、モーダル開閉、フォーム入力
TanStack Query はサーバー状態を管理します。クライアント状態には Zustand/Context を使用してください。
クエリキー
クエリキーはクエリとそのキャッシュデータを一意に識別します。
キー構造:
// 文字列キー(シンプル)
queryKey: ['todos']
// 配列キー(依存関係に推奨)
queryKey: ['todo', todoId]
queryKey: ['todos', { status: 'active', page: 1 }]
// ネストされた配列(複雑な階層構造)
queryKey: ['users', userId, 'posts', { sort: 'date' }]
キーマッチング:
// 完全一致
queryClient.invalidateQueries({ queryKey: ['todos', 1], exact: true });
// プリフィックスマッチ(マッチするすべてを無効化)
queryClient.invalidateQueries({ queryKey: ['todos'] }); // ['todos', 1], ['todos', 2] など にマッチ
// プリディケートマッチ
queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === 'todos' && query.state.data?.status === 'draft'
});
ベストプラクティス:
- 階層構造を持つ配列を使用:
['resource', id, 'subresource'] - 変数を最後に配置:
['users', { filter, sort }] - コンポーネント間で一貫した順序を保つ
- 複雑なパラメータにはオブジェクトを使用
クエリライフサイクル
FRESH → STALE → INACTIVE → GARBAGE COLLECTED
↓ ↓ ↓ ↓
0ms staleTime no observers cacheTime
状態:
- Fresh: データは最新と見なされる(
staleTime以内) - Stale: データが古い可能性があり、トリガー時に再取得
- Inactive: クエリを使用するコンポーネントがない
- Garbage Collected:
cacheTime後にキャッシュから削除
設定:
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: 5 * 60 * 1000, // 5 分(データ新鮮)
gcTime: 10 * 60 * 1000, // 10 分(キャッシュ保持)
refetchOnWindowFocus: true, // ウィンドウがフォーカスを取り戻したときに再取得
refetchOnReconnect: true, // 再接続時に再取得
refetchInterval: 30000, // 30 秒ごとにポーリング
});
キャッシュ動作
自動キャッシング:
// 最初のコンポーネント - 取得をトリガー
function ComponentA() {
const { data } = useQuery({ queryKey: ['user', 1], queryFn: fetchUser });
return <div>{data?.name}</div>;
}
// 2 番目のコンポーネント - キャッシュを即座に使用
function ComponentB() {
const { data } = useQuery({ queryKey: ['user', 1], queryFn: fetchUser });
return <div>{data?.email}</div>; // 2 番目の取得なし!
}
Stale-While-Revalidate:
// キャッシュデータをすぐに表示し、古い場合はバックグラウンドで再取得
const { data, isRefetching } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
staleTime: 60000, // 1 分間は新鮮
});
// data はキャッシュから即座に利用可能
// isRefetching = true if バックグラウンド再取得が実行中
クエリ
useQuery フック
基本的な構文:
const {
data, // クエリ結果
error, // 失敗した場合のエラーオブジェクト
isLoading, // 最初のロード(キャッシュデータなし)
isFetching, // すべての取得(バックグラウンド含む)
isSuccess, // クエリ成功
isError, // クエリ失敗
status, // 'pending' | 'error' | 'success'
fetchStatus, // 'fetching' | 'paused' | 'idle'
refetch, // 手動再取得関数
} = useQuery({
queryKey: ['key'],
queryFn: async () => { /* fetch logic */ },
});
クエリ関数パターン
基本的な Fetch:
const { data } = useQuery({
queryKey: ['users'],
queryFn: async () => {
const response = await fetch('/api/users');
if (!response.ok) throw new Error('Network error');
return response.json();
},
});
関数内のクエリキー:
const { data } = useQuery({
queryKey: ['user', userId],
queryFn: async ({ queryKey }) => {
const [_key, userId] = queryKey;
const response = await fetch(`/api/users/${userId}`);
return response.json();
},
});
Abort Signal(キャンセル):
const { data } = useQuery({
queryKey: ['todos'],
queryFn: async ({ signal }) => {
const response = await fetch('/api/todos', { signal });
return response.json();
},
});
// アンマウント時またはクエリが非アクティブになったときに自動的にキャンセル
Axios パターン:
import axios from 'axios';
const { data } = useQuery({
queryKey: ['repos', username],
queryFn: ({ signal }) =>
axios.get(`/api/repos/${username}`, { signal }).then(res => res.data),
});
依存クエリ
順序クエリ:
// ユーザーの取得後にプロジェクトを取得
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
const { data: projects } = useQuery({
queryKey: ['projects', user?.id],
queryFn: () => fetchProjects(user!.id),
enabled: !!user, // ユーザーが存在する場合のみ実行
});
条件付きクエリ:
const { data } = useQuery({
queryKey: ['premium-features', userId],
queryFn: fetchPremiumFeatures,
enabled: user?.isPremium === true, // プレミアムユーザーのみ取得
});
並列クエリ
手動並列:
function Dashboard() {
const users = useQuery({ queryKey: ['users'], queryFn: fetchUsers });
const posts = useQuery({ queryKey: ['posts'], queryFn: fetchPosts });
const projects = useQuery({ queryKey: ['projects'], queryFn: fetchProjects });
if (users.isLoading || posts.isLoading || projects.isLoading) {
return <Spinner />;
}
return <div>/* render dashboard */</div>;
}
useQueries(動的並列):
import { useQueries } from '@tanstack/react-query';
function MultiUserProfiles({ userIds }: { userIds: number[] }) {
const results = useQueries({
queries: userIds.map(id => ({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
staleTime: 60000,
})),
});
const allLoaded = results.every(r => r.isSuccess);
if (!allLoaded) return <Spinner />;
return (
<div>
{results.map((result, i) => (
<UserCard key={userIds[i]} user={result.data} />
))}
</div>
);
}
クエリプレースホルダー
プレースホルダーデータ(即座な UI):
const { data } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
placeholderData: [], // ロード中は空配列を表示
});
// キャッシュからのダイナミックプレースホルダー
const { data } = useQuery({
queryKey: ['todo', id],
queryFn: () => fetchTodo(id),
placeholderData: () => {
// キャッシュされたリストを使用してプレースホルダーを検索
return queryClient
.getQueryData(['todos'])
?.find(d => d.id === id);
},
});
初期データ(ハイドレーション):
const { data } = useQuery({
queryKey: ['todo', id],
queryFn: () => fetchTodo(id),
initialData: () => {
return queryClient
.getQueryData(['todos'])
?.find(d => d.id === id);
},
initialDataUpdatedAt: () =>
queryClient.getQueryState(['todos'])?.dataUpdatedAt,
});
違い:
placeholderData: キャッシュに永続化されない、純粋に UI 用initialData: キャッシュに永続化される実データ
ミューテーション
useMutation フック
基本的なミューテーション:
const mutation = useMutation({
mutationFn: async (newTodo: Todo) => {
const response = await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify(newTodo),
});
return response.json();
},
onSuccess: (data) => {
console.log('Created:', data);
},
onError: (error) => {
console.error('Failed:', error);
},
});
// ミューテーションをトリガー
mutation.mutate({ title: 'New Todo', done: false });
// async/await バリアント
try {
const data = await mutation.mutateAsync(newTodo);
console.log(data);
} catch (error) {
console.error(error);
}
ミューテーション状態:
const {
mutate, // トリガー関数
mutateAsync, // Promise バリアント
data, // 成功したミューテーションの結果
error, // 失敗したミューテーションのエラー
isPending, // ミューテーション進行中
isSuccess, // ミューテーション成功
isError, // ミューテーション失敗
reset, // ミューテーション状態をリセット
} = useMutation({ /* ... */ });
キャッシュ無効化
ミューテーション後にクエリを無効化:
const mutation = useMutation({
mutationFn: createTodo,
onSuccess: () => {
// すべての 'todos' クエリを再取得
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
複数の無効化:
const mutation = useMutation({
mutationFn: updateUser,
onSuccess: (data, variables) => {
// 複数のクエリファミリーを無効化
queryClient.invalidateQueries({ queryKey: ['user', variables.id] });
queryClient.invalidateQueries({ queryKey: ['users'] });
queryClient.invalidateQueries({ queryKey: ['teams', data.teamId] });
},
});
選択的な無効化:
// 特定のクエリのみ無効化
queryClient.invalidateQueries({
queryKey: ['todos'],
exact: true, // ['todos'] のみ、['todos', 1] ではない
});
// プリディケートベースの無効化
queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === 'todos' &&
query.state.data?.status === 'draft',
});
手動キャッシュ更新
setQueryData(直接更新):
const mutation = useMutation({
mutationFn: updateTodo,
onSuccess: (updatedTodo) => {
// キャッシュ内の特定の todo を更新
queryClient.setQueryData(
['todo', updatedTodo.id],
updatedTodo
);
// リスト内の todo を更新
queryClient.setQueryData(['todos'], (old: Todo[] = []) =>
old.map(todo =>
todo.id === updatedTodo.id ? updatedTodo : todo
)
);
},
});
イミュータブル更新:
// リストに追加
queryClient.setQueryData(['todos'], (old: Todo[] = []) =>
[...old, newTodo]
);
// リストから削除
queryClient.setQueryData(['todos'], (old: Todo[] = []) =>
old.filter(todo => todo.id !== deletedId)
);
// リスト内を更新
queryClient.setQueryData(['todos'], (old: Todo[] = []) =>
old.map(todo => todo.id === id ? { ...todo, ...updates } : todo)
);
楽観的更新
基本的な楽観的更新
const mutation = useMutation({
mutationFn: updateTodo,
// ミューテーション実行前
onMutate: async (newTodo) => {
// 送信されている再取得をキャンセル
await queryClient.cancelQueries({ queryKey: ['todos'] });
// 前の値をスナップショット
const previousTodos = queryClient.getQueryData(['todos']);
// キャッシュを楽観的に更新
queryClient.setQueryData(['todos'], (old: Todo[] = []) =>
old.map(todo => todo.id === newTodo.id ? newTodo : todo)
);
// スナップショットを含むコンテキストを返す
return { previousTodos };
},
// エラー時、ロールバック
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context?.previousTodos);
},
// 成功またはエラー時に常に再取得
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
複雑な楽観的更新パターン
interface Todo {
id: number;
title: string;
done: boolean;
}
const useUpdateTodo = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (updatedTodo: Todo) => {
const response = await fetch(`/api/todos/${updatedTodo.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updatedTodo),
});
if (!response.ok) throw new Error('Update failed');
return response.json();
},
onMutate: async (updatedTodo) => {
// レース条件を防ぐためにクエリをキャンセル
await queryClient.cancelQueries({ queryKey: ['todos'] });
await queryClient.cancelQueries({ queryKey: ['todo', updatedTodo.id] });
// 現在の状態をスナップショット
const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);
const previousTodo = queryClient.getQueryData<Todo>(['todo', updatedTodo.id]);
// リストを楽観的に更新
if (previousTodos) {
queryClient.setQueryData<Todo[]>(['todos'], (old = []) =>
old.map(todo => todo.id === updatedTodo.id ? updatedTodo : todo)
);
}
// 詳細を楽観的に更新
queryClient.setQueryData(['todo', updatedTodo.id], updatedTodo);
return { previousTodos, previousTodo };
},
onError: (err, updatedTodo, context) => {
// エラー時にロールバック
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos);
}
if (context?.previousTodo) {
queryClient.setQueryData(['todo', updatedTodo.id], context.previousTodo);
}
},
onSettled: (data, error, variables) => {
// 常に再取得して同期を確保
queryClient.invalidateQueries({ queryKey: ['todos'] });
queryClient.invalidateQueries({ queryKey: ['todo', variables.id] });
},
});
};
// 使用法
function TodoItem({ todo }: { todo: Todo }) {
const updateTodo = useUpdateTodo();
const toggleDone = () => {
updateTodo.mutate({ ...todo, done: !todo.done });
};
return (
<div>
<input
type="checkbox"
checked={todo.done}
onChange={toggleDone}
disabled={updateTodo.isPending}
/>
{todo.title}
</div>
);
}
ページネーション
useInfiniteQuery(無限スクロール)
基本的な無限クエリ:
import { useInfiniteQuery } from '@tanstack/react-query';
interface PostsResponse {
posts: Post[];
nextCursor?: number;
}
function InfinitePosts() {
const {
data,
error,
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 => (
<PostCard key={post.id} post={post} />
))}
</div>
))}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage
? 'Loading more...'
: hasNextPage
? 'Load More'
: 'Nothing more to load'}
</button>
</div>
);
}
双方向ページネーション:
const {
data,
fetchNextPage,
fetchPreviousPage,
hasNextPage,
hasPreviousPage,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: async ({ pageParam = 0 }) => {
const response = await fetch(`/api/posts?cursor=${pageParam}`);
return response.json();
},
getNextPageParam: (lastPage) => lastPage.nextCursor,
getPreviousPageParam: (firstPage) => firstPage.prevCursor,
initialPageParam: 0,
});
Intersection Observer による無限スクロール:
import { useInfiniteQuery } from '@tanstack/react-query';
import { useInView } from 'react-intersection-observer';
import { useEffect } from 'react';
function AutoLoadPosts() {
const { ref, inView } = useInView();
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: fetchPosts,
getNextPageParam: (lastPage) => lastPage.nextCursor,
initialPageParam: 0,
});
// センチネル要素がビューに入ったときに自動取得
useEffect(() => {
if (inView && hasNextPage) {
fetchNextPage();
}
}, [inView, hasNextPage, fetchNextPage]);
return (
<div>
{data?.pages.map((page, i) => (
<div key={i}>
{page.posts.map(post => <PostCard key={post.id} post={post} />)}
</div>
))}
{/* センチネル要素 */}
<div ref={ref}>
{isFetchingNextPage && <Spinner />}
</div>
</div>
);
}
従来型のページネーション
ページベースのページネーション:
function PaginatedPosts() {
const [page, setPage] = useState(1);
const { data, isLoading } = useQuery({
queryKey: ['posts', page],
queryFn: () => fetchPosts(page),
placeholderData: (previousData) => previousData, // ロード中は前のデータを保持
});
return (
<div>
{isLoading ? (
<Spinner />
) : (
<div>
{data.posts.map(post => <PostCard key={post.id} post={post} />)}
</div>
)}
<div>
<button
onClick={() => setPage(old => Math.max(old - 1, 1))}
disabled={page === 1}
>
Previous
</button>
<span>Page {page}</span>
<button
onClick={() => setPage(old => old + 1)}
disabled={!data?.hasMore}
>
Next
</button>
</div>
</div>
);
}
次のページをプリフェッチ:
function PaginatedPosts() {
const queryClient = useQueryClient();
const [page, setPage] = useState(1);
const { data } = useQuery({
queryKey: ['posts', page],
queryFn: () => fetchPosts(page),
});
// 次のページをプリフェッチ
useEffect(() => {
if (data?.hasMore) {
queryClient.prefetchQuery({
queryKey: ['posts', page + 1],
queryFn: () => fetchPosts(page + 1),
});
}
}, [data, page, queryClient]);
return (
<div>
{/* ... */}
</div>
);
}
キャッシュ管理
Query Client メソッド
getQueryData(キャッシュ読み取り):
const todos = queryClient.getQueryData<Todo[]>(['todos']);
const user = queryClient.getQueryData<User>(['user', userId]);
setQueryData(キャッシュ書き込み):
queryClient.setQueryData(['user', 1], newUser);
// 更新関数
queryClient.setQueryData<Todo[]>(['todos'], (old = []) => [...old, newTodo]);
invalidateQueries(古い状態にマーク + 再取得):
// すべてのクエリを無効化
queryClient.invalidateQueries();
// キープリフィックスで無効化
queryClient.invalidateQueries({ queryKey: ['todos'] });
// 完全一致のみ
queryClient.invalidateQueries({ queryKey: ['todos'], exact: true });
// 再取得制御付き
queryClient.invalidateQueries({
queryKey: ['todos'],
refetchType: 'active', // 'active' | 'inactive' | 'all' | 'none'
});
refetchQueries(即座の再取得):
// すべてのアクティブなクエリを再取得
await queryClient.refetchQueries();
// 特定のクエリを再取得
await queryClient.refetchQueries({ queryKey: ['todos'] });
// フィルター付きで再取得
await queryClient.refetchQueries({
queryKey: ['todos'],
type: 'active', // アクティブなクエリのみ再取得
});
removeQueries(キャッシュから削除):
// すべてのクエリを削除
queryClient.removeQueries();
// 特定のクエリを削除
queryClient.removeQueries({ queryKey: ['todos', 1] });
// プリディケートで削除
queryClient.removeQueries({
predicate: (query) =>
query.queryKey[0] === 'todos' &&
query.state.data?.isArchived === true,
});
resetQueries(初期状態にリセット):
// すべてのクエリをリセット
queryClient.resetQueries();
// 特定のクエリをリセット
queryClient.resetQueries({ queryKey: ['todos'] });
キャッシュ設定
グローバルデフォルト:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 分
gcTime: 5 * 60 * 1000, // 5 分(旧 cacheTime)
refetchOnWindowFocus: false,
refetchOnReconnect: true,
retry: 3,
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
},
mutations: {
retry: 1,
},
},
});
クエリごとの設定:
useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
staleTime: Infinity, // 古い状態にマークしない
gcTime: Infinity, // ガベージコレクションしない
refetchInterval: 5000, // 5 秒ごとに再取得
refetchIntervalInBackground: false, // タブが非アクティブな場合は再取得しない
});
キャッシュ永続化
LocalStorage への永続化:
import { QueryClient } from '@tanstack/react-query';
import { persistQueryClient } 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,
});
persistQueryClient({
queryClient,
persister,
maxAge: 1000 * 60 * 60 * 24, // 24 時間
});
IndexedDB 永続化:
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
import { get, set, del } from 'idb-keyval';
const persister = createAsyncStoragePersister({
storage: {
getItem: async (key) => await get(key),
setItem: async (key, value) => await set(key, value),
removeItem: async (key) => await del(key),
},
});
エラーハンドリングと再試行
エラーハンドリング
クエリエラーバウンダリー:
import { QueryErrorResetBoundary } from '@tanstack/react-query';
import { ErrorBoundary } from 'react-error-boundary';
function App() {
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ error, resetErrorBoundary }) => (
<div>
<p>Error: {error.message}</p>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
)}
>
<Component />
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
}
// コンポーネントはエラーをバウンダリーに投げる
function Component() {
const { data } = useQuery({
queryKey: ['user'],
queryFn: fetchUser,
throwOnError: true, // エラーをエラーバウンダリーに投げる
});
return <div>{data.name}</div>;
}
カスタムエラータイプ:
class APIError extends Error {
constructor(
message: string,
public status: number,
public code?: string
) {
super(message);
this.name = 'APIError';
}
}
const { error } = useQuery({
queryKey: ['user'],
queryFn: async () => {
const response = await fetch('/api/user');
if (!response.ok) {
throw new APIError(
'Failed to fetch user',
response.status,
await response.text()
);
}
return response.json();
},
});
if (error instanceof APIError) {
if (error.status === 404) return <NotFound />;
if (error.status === 401) return <Unauthorized />;
}
再試行ロジック
デフォルト再試行:
// 指数バックオフで 3 回再試行
useQuery({
queryKey: ['data'],
queryFn: fetchData,
retry: 3, // 再試行回数
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
});
条件付き再試行:
useQuery({
queryKey: ['data'],
queryFn: fetchData,
retry: (failureCount, error) => {
// 404 では再試行しない
if (error instanceof APIError && error.status === 404) {
return false;
}
// その他のエラーは最大 3 回再試行
return failureCount < 3;
},
});
ミューテーション再試行:
useMutation({
mutationFn: createUser,
retry: 2, // ミューテーション再試行(慎重に使用)
retryDelay: 1000,
});
ネットワーク状態検出
オンライン/オフライン処理:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
networkMode: 'offlineFirst', // 'online' | 'always' | 'offlineFirst'
refetchOnReconnect: true,
},
},
});
// カスタムオンライン/オフラインインジケーター
function OnlineStatus() {
const queryClient = useQueryClient();
const isOnline = useOnlineManager().isOnline();
useEffect(() => {
if (isOnline) {
queryClient.refetchQueries();
}
}, [isOnline, queryClient]);
return isOnline ? <OnlineIcon /> : <OfflineIcon />;
}
SSR とハイドレーション
Next.js App Router
サーバーコンポーネントデータ取得:
// app/users/page.tsx
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';
import { UsersList } from './UsersList';
export default async function UsersPage() {
const queryClient = new QueryClient();
// サーバーでプリフェッチ
await queryClient.prefetchQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<UsersList />
</HydrationBoundary>
);
}
クライアントコンポーネント:
// app/users/UsersList.tsx
'use client';
import { useQuery } from '@tanstack/react-query';
export function UsersList() {
// サーバーからハイドレーションされたデータを使用
const { data } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
return (
<ul>
{data?.map(user => <li key={user.id}>{user.name}</li>)}
</ul>
);
}
Next.js Pages Router
getServerSideProps:
import { dehydrate, QueryClient } from '@tanstack/react-query';
export async function getServerSideProps() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
return {
props: {
dehydratedState: dehydrate(queryClient),
},
};
}
function UsersPage() {
const { data } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
});
return <div>{/* ... */}</div>;
}
export default UsersPage;
_app.tsx セットアップ:
// pages/_app.tsx
import { useState } from 'react';
import { QueryClient, QueryClientProvider, HydrationBoundary } from '@tanstack/react-query';
export default function App({ Component, pageProps }) {
const [queryClient] = useState(() => new QueryClient());
return (
<QueryClientProvider client={queryClient}>
<HydrationBoundary state={pageProps.dehydratedState}>
<Component {...pageProps} />
</HydrationBoundary>
</QueryClientProvider>
);
}
ストリーミング SSR
Suspense インテグレーション:
import { useSuspenseQuery } from '@tanstack/react-query';
function UserProfile({ userId }: { userId: number }) {
const { data } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
// ローディング状態が不要 - Suspense が処理
return <div>{data.name}</div>;
}
// 親コンポーネント内
<Suspense fallback={<Spinner />}>
<UserProfile userId={1} />
</Suspense>
インテグレーションパターン
tRPC インテグレーション
セットアップ:
// utils/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/routers/_app';
export const trpc = createTRPCReact<AppRouter>();
プロバイダー:
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { useState } from 'react';
import { trpc } from '@/utils/trpc';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}
使用法:
function UserProfile() {
// クエリ
const { data } = trpc.user.getById.useQuery({ id: 1 });
// ミューテーション
const utils = trpc.useUtils();
const mutation = trpc.user.create.useMutation({
onSuccess: () => {
utils.user.list.invalidate();
},
});
return <div>{data?.name}</div>;
}
REST API と Axios
API クライアント:
// lib/api-client.ts
import axios from 'axios';
export const apiClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
headers: {
'Content-Type': 'application/json',
},
});
// リクエストインターセプター
apiClient.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// レスポンスインターセプター
apiClient.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
// 認可を処理
window.location.href = '/login';
}
return Promise.reject(error);
}
);
クエリフック:
// hooks/useUsers.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client';
export function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: async ({ signal }) => {
const { data } = await apiClient.get('/users', { signal });
return data;
},
});
}
export function useUser(id: number) {
return useQuery({
queryKey: ['user', id],
queryFn: async ({ signal }) => {
const { data } = await apiClient.get(`/users/${id}`, { signal });
return data;
},
enabled: !!id,
});
}
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (newUser: NewUser) =>
apiClient.post('/users', newUser).then(res => res.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
}
GraphQL インテグレーション
Apollo Client 代替:
import { useQuery } from '@tanstack/react-query';
import { request, gql } from 'graphql-request';
const endpoint = 'https://api.example.com/graphql';
const GET_USERS = gql`
query GetUsers {
users {
id
name
email
}
}
`;
function useUsers() {
return useQuery({
queryKey: ['users'],
queryFn: async () => request(endpoint, GET_USERS),
});
}
// 変数付き
const GET_USER = gql`
query GetUser($id: ID!) {
user(id: $id) {
id
name
email
}
}
`;
function useUser(id: string) {
return useQuery({
queryKey: ['user', id],
queryFn: async () => request(endpoint, GET_USER, { id }),
});
}
グローバル状態用の Zustand
結合パターン:
// store/useAuthStore.ts
import { create } from 'zustand';
interface AuthState {
token: string | null;
setToken: (token: string | null) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
token: localStorage.getItem('token'),
setToken: (token) => {
if (token) {
localStorage.setItem('token', token);
} else {
localStorage.removeItem('token');
}
set({ token });
},
logout: () => {
localStorage.removeItem('token');
set({ token: null });
},
}));
// hooks/useAuthenticatedQuery.ts
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '@/store/useAuthStore';
export function useAuthenticatedQuery() {
const token = useAuthStore(state => state.token);
return useQuery({
queryKey: ['profile', token],
queryFn: async () => {
const response = await fetch('/api/profile', {
headers: { Authorization: `Bearer ${token}` },
});
return response.json();
},
enabled: !!token,
});
}
TypeScript パターン
型付きクエリ
明示的な型付け:
interface User {
id: number;
name: string;
email: string;
}
// 明示的に型を指定
const { data } = useQuery<User, Error>({
queryKey: ['user', id],
queryFn: async () => {
const response = await fetch(`/api/users/${id}`);
return response.json(); // TypeScript は戻り値の型を推測
},
});
// data は User | undefined
// error は Error | null
型安全なクエリキー:
// 型付きのクエリキーを定義
const userKeys = {
all: ['users'] as const,
lists: () => [...userKeys.all, 'list'] as const,
list: (filters: UserFilters) => [...userKeys.lists(), filters] as const,
details: () => [...userKeys.all, 'detail'] as const,
detail: (id: number) => [...userKeys.details(), id] as const,
};
// 完全な型安全性で使用
const { data } = useQuery({
queryKey: userKeys.detail(userId),
queryFn: () => fetchUser(userId),
});
// 自動補完付きで無効化
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
型付きカスタムフック:
interface User {
id: number;
name: string;
email: string;
}
interface UseUserOptions {
enabled?: boolean;
onSuccess?: (user: User) => void;
}
function useUser(id: number, options?: UseUserOptions) {
return useQuery({
queryKey: ['user', id],
queryFn: async (): Promise<User> => {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error('Failed to fetch user');
return response.json();
},
enabled: options?.enabled,
// 型安全なコールバック
onSuccess: options?.onSuccess,
});
}
// 使用法
const { data } = useUser(1, {
enabled: true,
onSuccess: (user) => {
console.log(user.name); // TypeScript は user が User であることを認識
},
});
型付きミューテーション
interface CreateUserPayload {
name: string;
email: string;
}
interface User {
id: number;
name: string;
email: string;
}
function useCreateUser() {
return useMutation<User, Error, CreateUserPayload>({
mutationFn: async (payload) => {
const response = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(payload),
});
return response.json();
},
onSuccess: (data) => {
// data は User
console.log('Created user:', data.name);
},
onError: (error) => {
// error は Error
console.error('Failed:', error.message);
},
});
}
// 使用法
const mutation = useCreateUser();
mutation.mutate({ name: 'John', email: 'john@example.com' });
Query Client の型付け
import { QueryClient } from '@tanstack/react-query';
// 型安全な Query Client メソッド
const user = queryClient.getQueryData<User>(['user', 1]);
queryClient.setQueryData<User>(['user', 1], (old) => {
// old は User | undefined
if (!old) return old;
return { ...old, name: 'Updated' };
});
// 型安全な無効化
queryClient.invalidateQueries<User>({
queryKey: ['users'],
predicate: (query) => {
// query.state.data は User | undefined
return query.state.data?.isActive === true;
},
});
テスト
テスト環境セットアップ
テストユーティリティ:
// test/utils.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render } from '@testing-library/react';
import { ReactNode } from 'react';
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false, // テスト内で失敗したクエリの再試行をしない
gcTime: Infinity,
},
},
logger: {
log: console.log,
warn: console.warn,
error: () => {}, // テスト内でエラーを抑制
},
});
}
export function renderWithClient(ui: ReactNode) {
const testQueryClient = createTestQueryClient();
return render(
<QueryClientProvider client={testQueryClient}>
{ui}
</QueryClientProvider>
);
}
クエリのテスト
基本的なクエリテスト:
// UserProfile.test.tsx
import { renderWithClient } from '@/test/utils';
import { screen, waitFor } from '@testing-library/react';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { UserProfile } from './UserProfile';
const server = setupServer(
rest.get('/api/users/1', (req, res, ctx) => {
return res(
ctx.json({
id: 1,
name: 'John Doe',
email: 'john@example.com',
})
);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('displays user profile', async () => {
renderWithClient(<UserProfile userId={1} />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
});
test('handles fetch error', async () => {
server.use(
rest.get('/api/users/1', (req, res, ctx) => {
return res(ctx.status(500));
})
);
renderWithClient(<UserProfile userId={1} />);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
ミューテーションのテスト
// CreateUserForm.test.tsx
import { renderWithClient } from '@/test/utils';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { CreateUserForm } from './CreateUserForm';
const server = setupServer(
rest.post('/api/users', async (req, res, ctx) => {
const body = await req.json();
return res(
ctx.json({
id: 1,
...body,
})
);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('creates user successfully', async () => {
const user = userEvent.setup();
renderWithClient(<CreateUserForm />);
await user.type(screen.getByPlaceholderText('Name'), 'John Doe');
await user.type(screen.getByPlaceholderText('Email'), 'john@example.com');
await user.click(screen.getByRole('button', { name: /create/i }));
await waitFor(() => {
expect(screen.getByText(/created successfully/i)).toBeInTheDocument();
});
});
モックデータでのテスト
クエリデータをハイドレート:
test('renders with initial data', () => {
const testQueryClient = createTestQueryClient();
// キャッシュを事前に入力
testQueryClient.setQueryData(['user', 1], {
id: 1,
name: 'John Doe',
email: 'john@example.com',
});
render(
<QueryClientProvider client={testQueryClient}>
<UserProfile userId={1} />
</QueryClientProvider>
);
// データはすぐに利用可能(ローディング状態なし)
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
カスタムフックのテスト
// useUser.test.ts
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { useUser } from './useUser';
const server = setupServer(
rest.get('/api/users/:id', (req, res, ctx) => {
return res(ctx.json({ id: 1, name: 'John Doe' }));
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('fetches user data', async () => {
const queryClient = new QueryClient();
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useUser(1), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({ id: 1, name: 'John Doe' });
});
パフォーマンス最適化
クエリの重複排除
自動重複排除:
// 複数のコンポーネントが同じデータをリクエスト - 1 つのネットワークリクエストのみ
function Dashboard() {
return (
<div>
<UserStats userId={1} /> {/* 取得をトリガー */}
<UserProfile userId={1} /> {/* キャッシュを使用 */}
<UserActivity userId={1} /> {/* キャッシュを使用 */}
</div>
);
}
プリフェッチ
ホバープリフェッチ:
function UserLink({ userId }: { userId: number }) {
const queryClient = useQueryClient();
const prefetchUser = () => {
queryClient.prefetchQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
staleTime: 60000,
});
};
return (
<Link
href={`/users/${userId}`}
onMouseEnter={prefetchUser}
onFocus={prefetchUser}
>
View User
</Link>
);
}
ルートプリフェッチ:
// Next.js App Router
import { QueryClient, HydrationBoundary, dehydrate } from '@tanstack/react-query';
export default async function UserPage({ params }: { params: { id: string } }) {
const queryClient = new QueryClient();
// ユーザーデータをプリフェッチ
await queryClient.prefetchQuery({
queryKey: ['user', params.id],
queryFn: () => fetchUser(params.id),
});
// 関連データをプリフェッチ
await queryClient.prefetchQuery({
queryKey: ['user-posts', params.id],
ライセンス: MIT(寛容ライセンスのため全文を引用しています) · 原本リポジトリ
詳細情報
- 作者
- bobmatnyc
- ライセンス
- MIT
- 最終更新
- 不明
Source: https://github.com/bobmatnyc/claude-mpm-skills / ライセンス: MIT
関連スキル
doubt-driven-development
重要な判断はすべて、本番環境への展開前に新しい視点から対抗的レビューを実施します。速度より正確性が重要な場合、不慣れなコードを扱う場合、本番環境・セキュリティに関わるロジック・取り消し不可の操作など影響度が高い場合、または後でバグを修正するよりも今検証する方が効率的な場合に活用してください。
apprun-skills
TypeScriptを使用したAppRunアプリケーションのMVU設計に関する総合的なガイダンスが得られます。コンポーネントパターン、イベントハンドリング、状態管理(非同期ジェネレータを含む)、パラメータと保護機能を備えたルーティング・ナビゲーション、vistestを使用したテストに対応しています。AppRunコンポーネントの設計・レビュー、ルートの配線、状態フローの管理、AppRunテストの作成時に活用してください。
desloppify
コードベースのヘルスチェックと技術負債の追跡ツールです。コード品質、技術負債、デッドコード、大規模ファイル、ゴッドクラス、重複関数、コードスメル、命名規則の問題、インポートサイクル、結合度の問題についてユーザーが質問した場合に使用してください。また、ヘルススコアの確認、次の改善項目の提案、クリーンアップ計画の作成をリクエストされた際にも対応します。29言語に対応しています。
debugging-and-error-recovery
テストが失敗したり、ビルドが壊れたり、動作が期待と異なったり、予期しないエラーが発生したりした場合に、体系的な根本原因デバッグをガイドします。推測ではなく、根本原因を見つけて修正するための体系的なアプローチが必要な場合に使用してください。
test-driven-development
テスト駆動開発により実装を進めます。ロジックの実装、バグの修正、動作の変更など、あらゆる場面で活用できます。コードが正常に動作することを証明する必要がある場合、バグ報告を受けた場合、既存機能を修正する予定がある場合に使用してください。
incremental-implementation
変更を段階的に実施します。複数のファイルに影響する機能や変更を実装する場合に使用してください。大量のコードを一度に書こうとしている場合や、タスクが一度では完結できないほど大きい場合に活用します。