はじめに#
Reactアプリケーションにおいて、APIからのデータ取得やサーバー状態の管理は避けて通れない課題です。useEffectとuseStateを組み合わせた従来の方法では、ローディング状態の管理、エラーハンドリング、キャッシュ、再取得ロジックなど、多くのボイラープレートコードが必要になります。
TanStack Query(旧React Query)は、これらの課題を解決するためのサーバー状態管理ライブラリです。データのフェッチ、キャッシュ、同期、更新を宣言的に記述でき、複雑なサーバー状態管理のロジックを大幅に簡素化します。
本記事では、TanStack Queryの基本概念から実践的な活用パターンまでを解説します。
本記事を読むことで、以下のことができるようになります。
- TanStack Queryの基本概念と導入方法の理解
- useQueryを使った効率的なデータ取得
- useMutationによるデータ更新処理の実装
- キャッシュ戦略とstaleTimeの適切な設定
- 楽観的更新によるUX向上
- 無限スクロールの実装
実行環境・前提条件#
必要な環境#
- Node.js 20.x以上
- React 18以降
- TypeScript 5.x
- Viteで作成したReactプロジェクト(推奨)
- VS Code(推奨)
前提知識#
- Reactの基本的なHooks(useState、useEffect)
- TypeScriptの基本構文
- 非同期処理(async/await)の理解
- REST APIの基本概念
TanStack Queryの概要と特徴#
サーバー状態とクライアント状態の違い#
Reactアプリケーションで扱う状態は、大きく2種類に分類されます。
**クライアント状態(Client State)**は、UIの開閉状態、フォームの入力値、選択されたタブなど、クライアント側で完結する状態です。useStateやuseReducer、Zustandなどで管理するのが適切です。
**サーバー状態(Server State)**は、APIから取得したユーザー情報、商品リスト、投稿データなど、サーバーに保存されている状態のコピーです。サーバー状態には以下の特徴があります。
- リモートに永続化されており、自分がコントロールできない場所に存在する
- 取得や更新に非同期APIが必要
- 他のユーザーによって変更される可能性がある
- 適切に管理しないと「古い」状態になる
TanStack Queryは、このサーバー状態の管理に特化したライブラリです。
TanStack Queryの主要な特徴#
TanStack Queryが提供する主な機能は以下のとおりです。
| 機能 |
説明 |
| 自動キャッシュ |
取得したデータを自動的にキャッシュし、同じクエリの重複リクエストを防止 |
| バックグラウンド更新 |
ウィンドウフォーカス時や一定間隔でデータを自動的に再取得 |
| 状態管理の自動化 |
ローディング、エラー、成功状態を自動的に管理 |
| 楽観的更新 |
サーバー応答前にUIを先行更新し、失敗時にロールバック |
| 無限スクロール |
ページネーションや無限ローディングの実装を簡素化 |
| DevTools |
開発時にキャッシュ状態を視覚的にデバッグ |
セットアップ手順#
パッケージのインストール#
まず、TanStack Queryのパッケージをインストールします。
1
|
npm install @tanstack/react-query
|
開発時のデバッグを容易にするため、DevToolsもインストールすることを推奨します。
1
|
npm install @tanstack/react-query-devtools
|
QueryClientの設定#
TanStack Queryを使用するには、QueryClientを作成し、QueryClientProviderでアプリケーション全体をラップする必要があります。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
// src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import App from './App';
// QueryClientのインスタンスを作成
const queryClient = new QueryClient({
defaultOptions: {
queries: {
// デフォルトのstaleTime(データが古くなるまでの時間)
staleTime: 1000 * 60, // 1分
// デフォルトのgcTime(ガベージコレクションまでの時間)
gcTime: 1000 * 60 * 5, // 5分
// エラー時のリトライ回数
retry: 1,
// ウィンドウフォーカス時の再取得
refetchOnWindowFocus: true,
},
},
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
{/* 開発時のみDevToolsを表示 */}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</React.StrictMode>
);
|
基本的なuseQuery実装例#
シンプルなデータ取得#
useQueryは、サーバーからデータを取得するための基本的なフックです。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
// src/hooks/useTodos.ts
import { useQuery } from '@tanstack/react-query';
interface Todo {
id: number;
title: string;
completed: boolean;
userId: number;
}
// API呼び出し関数
async function fetchTodos(): Promise<Todo[]> {
const response = await fetch('https://jsonplaceholder.typicode.com/todos');
if (!response.ok) {
throw new Error('データの取得に失敗しました');
}
return response.json();
}
// カスタムフック
export function useTodos() {
return useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
}
|
このカスタムフックをコンポーネントで使用します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
// src/components/TodoList.tsx
import { useTodos } from '../hooks/useTodos';
export function TodoList() {
const { data, isPending, isError, error } = useTodos();
if (isPending) {
return <div>読み込み中...</div>;
}
if (isError) {
return <div>エラーが発生しました: {error.message}</div>;
}
return (
<ul>
{data.map((todo) => (
<li key={todo.id}>
{todo.completed ? '✓' : '○'} {todo.title}
</li>
))}
</ul>
);
}
|
Query Keyの設計#
queryKeyは、クエリを一意に識別するためのキーです。TanStack Queryはこのキーを使ってキャッシュを管理します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
// 基本的なキー
useQuery({ queryKey: ['todos'], queryFn: fetchTodos });
// パラメータを含むキー
useQuery({
queryKey: ['todos', { status: 'completed' }],
queryFn: () => fetchTodosByStatus('completed')
});
// IDを含むキー
useQuery({
queryKey: ['todo', todoId],
queryFn: () => fetchTodoById(todoId)
});
// 複数のパラメータ
useQuery({
queryKey: ['todos', userId, { page, limit }],
queryFn: () => fetchUserTodos(userId, page, limit)
});
|
Query Keyは配列として定義し、階層的に構成することで、関連するクエリをグループ化できます。これにより、queryClient.invalidateQueriesで特定のグループのキャッシュを一括で無効化できます。
依存クエリ(Dependent Queries)#
あるクエリの結果に基づいて別のクエリを実行する場合は、enabledオプションを使用します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
|
// src/hooks/useUserWithPosts.ts
import { useQuery } from '@tanstack/react-query';
interface User {
id: number;
name: string;
email: string;
}
interface Post {
id: number;
title: string;
body: string;
userId: number;
}
export function useUserWithPosts(userId: number) {
// まずユーザー情報を取得
const userQuery = useQuery({
queryKey: ['user', userId],
queryFn: async (): Promise<User> => {
const response = await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}`
);
return response.json();
},
});
// ユーザー情報の取得が成功したら投稿を取得
const postsQuery = useQuery({
queryKey: ['posts', userId],
queryFn: async (): Promise<Post[]> => {
const response = await fetch(
`https://jsonplaceholder.typicode.com/posts?userId=${userId}`
);
return response.json();
},
// userQueryが成功するまで実行しない
enabled: !!userQuery.data,
});
return { userQuery, postsQuery };
}
|
基本的なuseMutation実装例#
データの作成・更新・削除#
useMutationは、サーバーのデータを変更する処理(POST、PUT、DELETE)に使用します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
|
// src/hooks/useTodoMutations.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface Todo {
id: number;
title: string;
completed: boolean;
userId: number;
}
interface CreateTodoInput {
title: string;
userId: number;
}
// Todo作成のカスタムフック
export function useCreateTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (newTodo: CreateTodoInput): Promise<Todo> => {
const response = await fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...newTodo, completed: false }),
});
return response.json();
},
onSuccess: () => {
// 成功時にtodosのキャッシュを無効化して再取得
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
onError: (error) => {
console.error('Todo作成エラー:', error);
},
});
}
// Todo更新のカスタムフック
export function useUpdateTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (todo: Todo): Promise<Todo> => {
const response = await fetch(
`https://jsonplaceholder.typicode.com/todos/${todo.id}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(todo),
}
);
return response.json();
},
onSuccess: (data) => {
// 特定のTodoのキャッシュを更新
queryClient.setQueryData(['todo', data.id], data);
// リスト全体も無効化
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
}
// Todo削除のカスタムフック
export function useDeleteTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (todoId: number): Promise<void> => {
await fetch(`https://jsonplaceholder.typicode.com/todos/${todoId}`, {
method: 'DELETE',
});
},
onSuccess: (_, todoId) => {
// 削除されたTodoのキャッシュを削除
queryClient.removeQueries({ queryKey: ['todo', todoId] });
// リストを再取得
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
}
|
コンポーネントでの使用例#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
|
// src/components/TodoForm.tsx
import { useState } from 'react';
import { useCreateTodo } from '../hooks/useTodoMutations';
export function TodoForm() {
const [title, setTitle] = useState('');
const createTodo = useCreateTodo();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
createTodo.mutate(
{ title, userId: 1 },
{
onSuccess: () => {
setTitle(''); // フォームをリセット
},
}
);
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="新しいTodoを入力"
disabled={createTodo.isPending}
/>
<button type="submit" disabled={createTodo.isPending}>
{createTodo.isPending ? '追加中...' : '追加'}
</button>
{createTodo.isError && (
<p style={{ color: 'red' }}>エラーが発生しました</p>
)}
</form>
);
}
|
キャッシュ戦略とstaleTimeの活用#
staleTimeとgcTimeの理解#
TanStack Queryのキャッシュ動作を理解するには、2つの重要な設定を把握する必要があります。
staleTimeは、データが「新鮮(fresh)」とみなされる時間です。この時間内は再取得が発生しません。デフォルトは0(即座に古くなる)です。
gcTime(旧cacheTime)は、未使用のキャッシュがメモリから削除されるまでの時間です。デフォルトは5分です。
sequenceDiagram
participant C as コンポーネント
participant Q as TanStack Query
participant S as サーバー
C->>Q: useQuery呼び出し
Q->>S: データ取得
S-->>Q: レスポンス
Q-->>C: データ表示(fresh状態)
Note over Q: staleTime経過後<br/>stale状態に変化
C->>Q: 再マウント
Q-->>C: キャッシュから即座に表示
Q->>S: バックグラウンドで再取得
S-->>Q: 新しいデータ
Q-->>C: 更新されたデータを表示ユースケース別のstaleTime設定#
データの特性に応じて適切なstaleTimeを設定することで、パフォーマンスとデータの鮮度のバランスを取ることができます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
|
// src/hooks/useOptimizedQueries.ts
import { useQuery } from '@tanstack/react-query';
// ほぼ変更されないデータ(マスタデータなど)
export function useCategories() {
return useQuery({
queryKey: ['categories'],
queryFn: fetchCategories,
staleTime: 1000 * 60 * 60, // 1時間
gcTime: 1000 * 60 * 60 * 24, // 24時間
});
}
// 頻繁に変更されるデータ(通知など)
export function useNotifications() {
return useQuery({
queryKey: ['notifications'],
queryFn: fetchNotifications,
staleTime: 1000 * 30, // 30秒
refetchInterval: 1000 * 60, // 1分ごとに自動再取得
});
}
// ユーザーセッション中は変わらないデータ
export function useCurrentUser() {
return useQuery({
queryKey: ['currentUser'],
queryFn: fetchCurrentUser,
staleTime: Infinity, // 明示的に無効化するまで再取得しない
});
}
// リアルタイム性が重要なデータ
export function useStockPrice(symbol: string) {
return useQuery({
queryKey: ['stock', symbol],
queryFn: () => fetchStockPrice(symbol),
staleTime: 0, // 常にstale
refetchInterval: 1000 * 5, // 5秒ごとに更新
});
}
|
クエリの無効化パターン#
Mutationの成功後にキャッシュを更新する方法はいくつかあります。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
import { useQueryClient } from '@tanstack/react-query';
function useInvalidationPatterns() {
const queryClient = useQueryClient();
// 特定のクエリを無効化
const invalidateSpecific = () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
};
// プレフィックスマッチで複数のクエリを無効化
const invalidateByPrefix = () => {
// ['todos'], ['todos', 1], ['todos', { status: 'active' }] などすべて無効化
queryClient.invalidateQueries({ queryKey: ['todos'] });
};
// 完全一致で無効化
const invalidateExact = () => {
queryClient.invalidateQueries({
queryKey: ['todos', 1],
exact: true // このキーのみ
});
};
// すべてのクエリを無効化(ログアウト時など)
const invalidateAll = () => {
queryClient.invalidateQueries();
};
// キャッシュを直接更新
const updateCache = (newTodo: Todo) => {
queryClient.setQueryData(['todos'], (old: Todo[] | undefined) =>
old ? [...old, newTodo] : [newTodo]
);
};
}
|
楽観的更新の実装例#
楽観的更新(Optimistic Updates)は、サーバーの応答を待たずにUIを先行更新し、ユーザー体験を向上させる手法です。
UIベースの楽観的更新#
最もシンプルな楽観的更新は、Mutationのvariablesを使ってUIに反映する方法です。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
|
// src/components/OptimisticTodoList.tsx
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
interface Todo {
id: number;
title: string;
completed: boolean;
}
export function OptimisticTodoList() {
const queryClient = useQueryClient();
const { data: todos = [] } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
const addTodoMutation = useMutation({
mutationFn: async (title: string) => {
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, completed: false }),
});
return response.json();
},
onSettled: () => {
// 成功・失敗に関わらず再取得
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
{/* Mutation中は楽観的にアイテムを表示 */}
{addTodoMutation.isPending && (
<li style={{ opacity: 0.5 }}>{addTodoMutation.variables}</li>
)}
{/* エラー時はリトライボタンを表示 */}
{addTodoMutation.isError && (
<li style={{ color: 'red' }}>
{addTodoMutation.variables}
<button onClick={() => addTodoMutation.mutate(addTodoMutation.variables!)}>
再試行
</button>
</li>
)}
</ul>
);
}
|
キャッシュベースの楽観的更新#
より高度な楽観的更新では、キャッシュを直接操作し、エラー時にロールバックします。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
|
// src/hooks/useOptimisticTodo.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface Todo {
id: number;
title: string;
completed: boolean;
}
export function useOptimisticUpdateTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (updatedTodo: Todo): Promise<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('更新に失敗しました');
return response.json();
},
// Mutation開始前に実行
onMutate: async (newTodo, context) => {
// 進行中のrefetchをキャンセル(楽観的更新を上書きしないため)
await context.client.cancelQueries({ queryKey: ['todos'] });
// 現在のキャッシュをスナップショット
const previousTodos = context.client.getQueryData<Todo[]>(['todos']);
// 楽観的にキャッシュを更新
context.client.setQueryData<Todo[]>(['todos'], (old) =>
old?.map((todo) => (todo.id === newTodo.id ? newTodo : todo))
);
// ロールバック用にスナップショットを返す
return { previousTodos };
},
// エラー時はロールバック
onError: (err, newTodo, onMutateResult, context) => {
if (onMutateResult?.previousTodos) {
context.client.setQueryData(['todos'], onMutateResult.previousTodos);
}
},
// 成功・エラーに関わらず最新データを取得
onSettled: (data, error, variables, onMutateResult, context) => {
context.client.invalidateQueries({ queryKey: ['todos'] });
},
});
}
|
コンポーネントでの使用#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
|
// src/components/TodoItem.tsx
import { useOptimisticUpdateTodo } from '../hooks/useOptimisticTodo';
interface Todo {
id: number;
title: string;
completed: boolean;
}
interface TodoItemProps {
todo: Todo;
}
export function TodoItem({ todo }: TodoItemProps) {
const updateTodo = useOptimisticUpdateTodo();
const handleToggle = () => {
updateTodo.mutate({
...todo,
completed: !todo.completed,
});
};
return (
<li
onClick={handleToggle}
style={{
textDecoration: todo.completed ? 'line-through' : 'none',
opacity: updateTodo.isPending ? 0.5 : 1,
cursor: 'pointer',
}}
>
{todo.completed ? '✓' : '○'} {todo.title}
</li>
);
}
|
無限スクロールの実装例#
useInfiniteQueryを使用すると、ページネーションや無限スクロールを簡単に実装できます。
基本的なuseInfiniteQuery#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
|
// src/hooks/useInfinitePosts.ts
import { useInfiniteQuery } from '@tanstack/react-query';
interface Post {
id: number;
title: string;
body: string;
}
interface PostsResponse {
data: Post[];
nextPage: number | null;
totalPages: number;
}
async function fetchPosts(page: number): Promise<PostsResponse> {
const response = await fetch(`/api/posts?page=${page}&limit=10`);
if (!response.ok) throw new Error('データの取得に失敗しました');
return response.json();
}
export function useInfinitePosts() {
return useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }) => fetchPosts(pageParam),
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.nextPage,
getPreviousPageParam: (firstPage) =>
firstPage.nextPage ? firstPage.nextPage - 2 : null,
});
}
|
無限スクロールコンポーネント#
Intersection Observer APIと組み合わせて、スクロール時に自動的に次のページを読み込みます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
|
// src/components/InfinitePostList.tsx
import { useEffect, useRef } from 'react';
import { useInfinitePosts } from '../hooks/useInfinitePosts';
export function InfinitePostList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isPending,
isError,
error,
} = useInfinitePosts();
const loadMoreRef = useRef<HTMLDivElement>(null);
// Intersection Observerで自動読み込み
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ threshold: 0.1 }
);
const currentRef = loadMoreRef.current;
if (currentRef) {
observer.observe(currentRef);
}
return () => {
if (currentRef) {
observer.unobserve(currentRef);
}
};
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
if (isPending) {
return <div>読み込み中...</div>;
}
if (isError) {
return <div>エラー: {error.message}</div>;
}
return (
<div>
{/* すべてのページのデータをフラット化して表示 */}
{data.pages.map((page, pageIndex) => (
<div key={pageIndex}>
{page.data.map((post) => (
<article key={post.id} style={{ marginBottom: '1rem' }}>
<h3>{post.title}</h3>
<p>{post.body}</p>
</article>
))}
</div>
))}
{/* ローディングインジケーター・読み込みトリガー */}
<div ref={loadMoreRef} style={{ padding: '1rem', textAlign: 'center' }}>
{isFetchingNextPage ? (
<span>読み込み中...</span>
) : hasNextPage ? (
<span>スクロールして続きを読み込む</span>
) : (
<span>すべて読み込みました</span>
)}
</div>
</div>
);
}
|
手動ページネーション#
ボタンクリックで次のページを読み込む従来のページネーションも可能です。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
|
// src/components/PaginatedPostList.tsx
import { useInfinitePosts } from '../hooks/useInfinitePosts';
export function PaginatedPostList() {
const {
data,
fetchNextPage,
fetchPreviousPage,
hasNextPage,
hasPreviousPage,
isFetchingNextPage,
isFetchingPreviousPage,
isPending,
} = useInfinitePosts();
if (isPending) return <div>読み込み中...</div>;
// 現在表示中のページ(最後のページ)
const currentPage = data?.pages[data.pages.length - 1];
return (
<div>
{currentPage?.data.map((post) => (
<article key={post.id}>
<h3>{post.title}</h3>
<p>{post.body}</p>
</article>
))}
<div style={{ display: 'flex', gap: '1rem', marginTop: '1rem' }}>
<button
onClick={() => fetchPreviousPage()}
disabled={!hasPreviousPage || isFetchingPreviousPage}
>
{isFetchingPreviousPage ? '読み込み中...' : '前のページ'}
</button>
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage ? '読み込み中...' : '次のページ'}
</button>
</div>
</div>
);
}
|
実務での活用パターン#
APIクライアントの抽象化#
実務では、API呼び出しを抽象化し、型安全性を確保することが重要です。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
|
// src/lib/apiClient.ts
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api';
class ApiError extends Error {
constructor(
public status: number,
message: string
) {
super(message);
this.name = 'ApiError';
}
}
async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const message = await response.text();
throw new ApiError(response.status, message || 'APIエラーが発生しました');
}
return response.json();
}
export const apiClient = {
get: async <T>(endpoint: string): Promise<T> => {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
});
return handleResponse<T>(response);
},
post: async <T, D>(endpoint: string, data: D): Promise<T> => {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(data),
});
return handleResponse<T>(response);
},
put: async <T, D>(endpoint: string, data: D): Promise<T> => {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(data),
});
return handleResponse<T>(response);
},
delete: async <T>(endpoint: string): Promise<T> => {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
method: 'DELETE',
credentials: 'include',
});
return handleResponse<T>(response);
},
};
|
Query Factoryパターン#
クエリのオプションを一元管理するパターンです。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
|
// src/queries/todoQueries.ts
import { queryOptions, infiniteQueryOptions } from '@tanstack/react-query';
import { apiClient } from '../lib/apiClient';
interface Todo {
id: number;
title: string;
completed: boolean;
}
interface TodosResponse {
data: Todo[];
nextPage: number | null;
}
// クエリオプションをファクトリ関数で生成
export const todoQueries = {
all: () => queryOptions({
queryKey: ['todos'] as const,
queryFn: () => apiClient.get<Todo[]>('/todos'),
staleTime: 1000 * 60 * 5, // 5分
}),
detail: (id: number) => queryOptions({
queryKey: ['todos', id] as const,
queryFn: () => apiClient.get<Todo>(`/todos/${id}`),
staleTime: 1000 * 60 * 5,
}),
byStatus: (status: 'all' | 'active' | 'completed') => queryOptions({
queryKey: ['todos', { status }] as const,
queryFn: () => apiClient.get<Todo[]>(`/todos?status=${status}`),
staleTime: 1000 * 60,
}),
infinite: () => infiniteQueryOptions({
queryKey: ['todos', 'infinite'] as const,
queryFn: ({ pageParam }) =>
apiClient.get<TodosResponse>(`/todos?page=${pageParam}`),
initialPageParam: 1,
getNextPageParam: (lastPage) => lastPage.nextPage,
}),
};
|
使用例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
// src/components/TodoDetail.tsx
import { useQuery } from '@tanstack/react-query';
import { todoQueries } from '../queries/todoQueries';
export function TodoDetail({ id }: { id: number }) {
// ファクトリ関数から生成したオプションを使用
const { data, isPending } = useQuery(todoQueries.detail(id));
if (isPending) return <div>読み込み中...</div>;
return (
<div>
<h2>{data?.title}</h2>
<p>状態: {data?.completed ? '完了' : '未完了'}</p>
</div>
);
}
|
エラーバウンダリとの統合#
TanStack QueryはReactのSuspenseとError Boundaryと統合できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
|
// src/components/TodoListWithSuspense.tsx
import { useSuspenseQuery } from '@tanstack/react-query';
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { todoQueries } from '../queries/todoQueries';
function TodoListContent() {
// useSuspenseQueryはローディング中にSuspenseをトリガー
const { data } = useSuspenseQuery(todoQueries.all());
return (
<ul>
{data.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}
function ErrorFallback({ error, resetErrorBoundary }: {
error: Error;
resetErrorBoundary: () => void;
}) {
return (
<div role="alert">
<p>エラーが発生しました:</p>
<pre>{error.message}</pre>
<button onClick={resetErrorBoundary}>再試行</button>
</div>
);
}
export function TodoListWithSuspense() {
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Suspense fallback={<div>読み込み中...</div>}>
<TodoListContent />
</Suspense>
</ErrorBoundary>
);
}
|
期待される結果#
本記事の内容を実践することで、以下の成果が期待できます。
-
コード量の削減: useEffectとuseStateを組み合わせた従来の実装と比較して、データ取得ロジックのコード量が50%以上削減されます。
-
パフォーマンスの向上: 自動キャッシュにより、同じデータへの重複リクエストが排除され、ネットワーク負荷とレンダリング回数が大幅に減少します。
-
ユーザー体験の改善: 楽観的更新により、ユーザーの操作に対する応答が即座に行われ、体感速度が向上します。
-
保守性の向上: Query Factoryパターンにより、APIロジックが一元管理され、変更時の影響範囲が明確になります。
-
デバッグ効率の向上: DevToolsを活用することで、キャッシュの状態やクエリの実行状況を視覚的に確認でき、問題の特定が容易になります。
まとめ#
TanStack Queryは、Reactアプリケーションにおけるサーバー状態管理の複雑さを大幅に軽減するライブラリです。本記事で解説した内容を振り返ります。
useQueryによるデータ取得と自動的な状態管理
useMutationによるデータ更新とキャッシュの無効化
staleTimeとgcTimeを活用したキャッシュ戦略
- 楽観的更新によるUXの向上
useInfiniteQueryによる無限スクロールの実装
- 実務で活用できるQuery Factoryパターン
従来のuseEffectベースのデータ取得から移行することで、より宣言的で保守性の高いコードを書くことができます。まずは既存プロジェクトの一部分から導入を始め、徐々に適用範囲を広げていくことをお勧めします。
参考リンク#