はじめに
前回の記事ではJotaiとZustandを使ったReactのグローバルState管理を解説しました。APIから取得したデータも状態管理の一部ですが、「いつキャッシュを使うか」「いつ再取得するか」「エラーをどう扱うか」をZustandやJotaiで一から実装するのは複雑になります。今回はTanStack Query(旧React Query)を使って、APIデータの取得・キャッシュ・リフレッシュ・ミューテーション(更新・削除)を効率よく管理する方法を解説します。
1. TanStack Queryとは
1.1 何を解決するライブラリか
これまでの連載では useEffect + useState でAPIデータを取得してきましたが、実際の開発では以下の課題があります。
useEffect + useStateによるデータ取得の課題:
❌ ローディング・エラー・データの状態を毎回自分で管理する
❌ 同じAPIを複数のコンポーネントで呼ぶと重複リクエストが発生する
❌ ページを再表示するたびにAPIを叩き直す(キャッシュがない)
❌ 他のタブで更新したデータがすぐに反映されない
❌ 投稿・更新・削除後にリストを再取得する処理が煩雑になる
TanStack Queryはこれらを解決します。
| 機能 | 内容 |
|---|---|
| 自動キャッシュ | 同じクエリの結果を自動でキャッシュする |
| 重複排除 | 同時に同じリクエストが飛んでも1回しか実行しない |
| バックグラウンド更新 | キャッシュを返しながらバックグラウンドで最新データを取得する |
| エラーリトライ | エラー時に自動でリトライする |
| ウィンドウフォーカス更新 | タブを切り替えて戻るとデータを自動更新する |
| ページネーション・無限スクロール | 組み込みのサポートがある |
1.2 インストール
bash
npm install @tanstack/react-query
# DevTools(開発時のみ使う)
npm install -D @tanstack/react-query-devtools
2. 初期設定
2.1 QueryClientの設定
tsx
// src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { RouterProvider, createRouter } from '@tanstack/react-router';
import { routeTree } from './routeTree.gen';
// QueryClientのインスタンスを作成する
const queryClient = new QueryClient( {
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5分間はキャッシュを新鮮とみなす
gcTime: 1000 * 60 * 10, // 10分後に未使用キャッシュを削除する
retry: 2, // エラー時に最大2回リトライする
refetchOnWindowFocus: true, // ウィンドウフォーカス時に再取得する
},
mutations: {
retry: 0, // ミューテーションはリトライしない
},
},
} );
const router = createRouter( { routeTree } );
createRoot( document.getElementById( 'root' )! ).render(
<StrictMode>
{/* QueryClientProviderでアプリ全体をラップする */}
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
{/* 開発環境でのみDevToolsを表示する */}
{import.meta.env.DEV && (
<ReactQueryDevtools initialIsOpen={false} />
)}
</QueryClientProvider>
</StrictMode>
);
💡 staleTime と gcTime の違いを理解しましょう:
staleTime(データが「古い」とみなすまでの時間):
→ staleTime内はキャッシュをそのまま返す(再取得しない)
→ staleTimeを過ぎたデータはバックグラウンドで再取得される
gcTime(ガベージコレクションまでの時間):
→ コンポーネントがアンマウントされてから gcTime 後にキャッシュを削除する
→ 削除されるまでは別のコンポーネントが同じクエリを使うとキャッシュから返る
3. useQueryでデータを取得する
3.1 基本的な使い方
tsx
// src/components/PostList.tsx
import { useQuery } from '@tanstack/react-query';
import type { Post } from '@/types/post';
// APIリクエスト関数(fetcher)を外に定義する
async function fetchPosts(): Promise<Post[]> {
const res = await fetch( 'https://jsonplaceholder.typicode.com/posts?_limit=12' );
if ( !res.ok ) throw new Error( `HTTP Error: ${res.status}` );
return res.json();
}
function PostList() {
const {
data: posts, // 取得したデータ
isLoading, // 初回ローディング中
isFetching, // バックグラウンド再取得中
isError, // エラーが発生した
error, // エラーオブジェクト
refetch, // 手動で再取得する
} = useQuery( {
queryKey: ['posts'], // キャッシュのキー(配列で指定する)
queryFn: fetchPosts, // データを取得する関数
staleTime: 1000 * 60, // このクエリは1分間キャッシュを新鮮とみなす
} );
if ( isLoading ) return <PostListSkeleton />;
if ( isError ) return (
<div className="error-state" role="alert">
<p>{error.message}</p>
<button onClick={() => refetch()}>再試行する</button>
</div>
);
return (
<div className="posts-grid">
{/* バックグラウンド更新中のインジケーター */}
{isFetching && <div className="update-indicator">更新中...</div>}
{posts?.map( post => (
<PostCard key={post.id} post={post} />
) )}
</div>
);
}
💡 isLoading と isFetching の違いに注意しましょう:
isLoading:キャッシュがない状態での初回ローディング(true の間はデータなし)
isFetching:バックグラウンドでの再取得中も含む(キャッシュデータを表示しながらtrue)
→ スケルトンUIは isLoading を使う
→ 「更新中」インジケーターは isFetching を使う
3.2 queryKeyで動的なクエリを管理する
// queryKeyにパラメータを含めると、パラメータごとに別々のキャッシュになる
async function fetchPost( postId: number ): Promise<Post> {
const res = await fetch(
`https://jsonplaceholder.typicode.com/posts/${postId}`
);
if ( !res.ok ) {
if ( res.status === 404 ) throw new Error( '記事が見つかりません' );
throw new Error( `HTTP Error: ${res.status}` );
}
return res.json();
}
function PostDetail( { postId }: { postId: number } ) {
const { data: post, isLoading, isError, error } = useQuery( {
queryKey: ['posts', postId], // postIdをキーに含める
queryFn: () => fetchPost( postId ),
enabled: postId > 0, // postIdが正の数のときだけ実行する
} );
if ( isLoading ) return <div>読み込み中...</div>;
if ( isError ) return <div>{error.message}</div>;
if ( !post ) return null;
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
</article>
);
}
3.3 クエリを再利用するカスタムフックを作る
// src/hooks/queries/usePostsQuery.ts
import { useQuery } from '@tanstack/react-query';
import type { Post } from '@/types/post';
// queryKeyを定数として管理するファクトリ
export const postKeys = {
all: () => ['posts'] as const,
lists: () => [...postKeys.all(), 'list'] as const,
list: ( page: number ) => [...postKeys.lists(), { page }] as const,
detail: ( id: number ) => [...postKeys.all(), id] as const,
};
// 投稿一覧を取得するカスタムフック
export function usePostsQuery( page: number = 1 ) {
return useQuery( {
queryKey: postKeys.list( page ),
queryFn: async () => {
const res = await fetch(
`https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=10`
);
if ( !res.ok ) throw new Error( `HTTP Error: ${res.status}` );
return res.json() as Promise<Post[]>;
},
placeholderData: ( prev ) => prev, // ページ切り替え時に前のデータを維持する
} );
}
// 個別投稿を取得するカスタムフック
export function usePostQuery( postId: number ) {
return useQuery( {
queryKey: postKeys.detail( postId ),
queryFn: async () => {
const res = await fetch(
`https://jsonplaceholder.typicode.com/posts/${postId}`
);
if ( !res.ok ) throw new Error( `HTTP Error: ${res.status}` );
return res.json() as Promise<Post>;
},
enabled: postId > 0,
} );
}
4. ページネーションと無限スクロール
4.1 ページネーション
// src/components/PaginatedPostList.tsx
import { useState } from 'react';
import { usePostsQuery } from '@/hooks/queries/usePostsQuery';
function PaginatedPostList() {
const [page, setPage] = useState( 1 );
const { data, isLoading, isFetching, isPlaceholderData } = usePostsQuery( page );
return (
<div>
{isLoading ? (
<PostListSkeleton />
) : (
<div className={`posts-grid ${isFetching ? 'opacity-70' : ''}`}>
{data?.map( post => (
<PostCard key={post.id} post={post} />
) )}
</div>
)}
<div className="pagination">
<button
onClick={() => setPage( p => p - 1 )}
disabled={page === 1}
className="btn btn--outline"
>
« 前のページ
</button>
<span>ページ {page}</span>
<button
onClick={() => setPage( p => p + 1 )}
// 前のデータを表示中のときは「次へ」を無効にしない
disabled={isPlaceholderData || ( data?.length ?? 0 ) < 10}
className="btn btn--outline"
>
次のページ »
</button>
</div>
</div>
);
}
4.2 無限スクロール
// src/hooks/queries/useInfinitePostsQuery.ts
import { useInfiniteQuery } from '@tanstack/react-query';
import type { Post } from '@/types/post';
export function useInfinitePostsQuery() {
return useInfiniteQuery( {
queryKey: ['posts', 'infinite'],
queryFn: async ( { pageParam } ) => {
const res = await fetch(
`https://jsonplaceholder.typicode.com/posts?_page=${pageParam}&_limit=10`
);
if ( !res.ok ) throw new Error( `HTTP Error: ${res.status}` );
return res.json() as Promise<Post[]>;
},
initialPageParam: 1,
getNextPageParam: ( lastPage, _allPages, lastPageParam ) => {
// 最後のページが空だったら次のページはない
if ( lastPage.length === 0 ) return undefined;
return lastPageParam + 1;
},
} );
}
// src/components/InfinitePostList.tsx
import { useEffect, useRef } from 'react';
import { useInfinitePostsQuery } from '@/hooks/queries/useInfinitePostsQuery';
function InfinitePostList() {
const sentinelRef = useRef<HTMLDivElement>( null );
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useInfinitePostsQuery();
// IntersectionObserverで末尾を監視して自動ロードする
useEffect( () => {
const observer = new IntersectionObserver(
entries => {
if ( entries[0].isIntersecting && hasNextPage && !isFetchingNextPage ) {
fetchNextPage();
}
},
{ rootMargin: '200px' }
);
const sentinel = sentinelRef.current;
if ( sentinel ) observer.observe( sentinel );
return () => { if ( sentinel ) observer.unobserve( sentinel ); };
}, [hasNextPage, isFetchingNextPage, fetchNextPage] );
if ( isLoading ) return <PostListSkeleton />;
// pagesはページごとの配列なのでflatMapで1次元にする
const posts = data?.pages.flatMap( page => page ) ?? [];
return (
<div>
<div className="posts-grid">
{posts.map( post => (
<PostCard key={post.id} post={post} />
) )}
</div>
{/* 監視対象の末尾要素 */}
<div ref={sentinelRef} className="scroll-sentinel">
{isFetchingNextPage && <LoadingSpinner />}
{!hasNextPage && <p className="end-message">すべての記事を表示しました。</p>}
</div>
</div>
);
}
5. useMutationでデータを更新する
5.1 投稿の作成・更新・削除
// src/hooks/mutations/usePostMutations.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { postKeys } from '@/hooks/queries/usePostsQuery';
import type { Post } from '@/types/post';
// 投稿を作成するミューテーション
export function useCreatePost() {
const queryClient = useQueryClient();
return useMutation( {
mutationFn: async ( newPost: Pick<Post, 'title' | 'body' | 'userId'> ) => {
const res = await fetch( 'https://jsonplaceholder.typicode.com/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify( newPost ),
} );
if ( !res.ok ) throw new Error( `HTTP Error: ${res.status}` );
return res.json() as Promise<Post>;
},
// 成功時:投稿一覧のキャッシュを無効化して再取得する
onSuccess: () => {
queryClient.invalidateQueries( { queryKey: postKeys.lists() } );
},
// エラー時
onError: ( error ) => {
console.error( '投稿の作成に失敗しました:', error.message );
},
} );
}
// 投稿を削除するミューテーション
export function useDeletePost() {
const queryClient = useQueryClient();
return useMutation( {
mutationFn: async ( postId: number ) => {
const res = await fetch(
`https://jsonplaceholder.typicode.com/posts/${postId}`,
{ method: 'DELETE' }
);
if ( !res.ok ) throw new Error( `HTTP Error: ${res.status}` );
},
// 楽観的更新(APIの完了前にUIを更新する)
onMutate: async ( postId ) => {
// 実行中のクエリをキャンセルする
await queryClient.cancelQueries( { queryKey: postKeys.lists() } );
// 削除前のデータを保存しておく(ロールバック用)
const previous = queryClient.getQueryData( postKeys.lists() );
// 楽観的にキャッシュからデータを削除する
queryClient.setQueryData<Post[]>( postKeys.lists(), old =>
old?.filter( post => post.id !== postId ) ?? []
);
return { previous };
},
// エラー時:楽観的更新を元に戻す
onError: ( _error, _postId, context ) => {
if ( context?.previous ) {
queryClient.setQueryData( postKeys.lists(), context.previous );
}
},
// 成功・失敗に関わらず再取得する
onSettled: () => {
queryClient.invalidateQueries( { queryKey: postKeys.lists() } );
},
} );
}
5.2 ミューテーションをコンポーネントで使う
// src/components/PostForm.tsx
import { useState } from 'react';
import { useCreatePost } from '@/hooks/mutations/usePostMutations';
function PostForm() {
const [title, setTitle] = useState( '' );
const [body, setBody] = useState( '' );
const createPost = useCreatePost();
const handleSubmit = async ( e: React.FormEvent ) => {
e.preventDefault();
createPost.mutate(
{ title, body, userId: 1 },
{
onSuccess: ( newPost ) => {
console.log( '作成された投稿:', newPost );
setTitle( '' );
setBody( '' );
},
}
);
};
return (
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="title">タイトル</label>
<input
id="title"
value={title}
onChange={e => setTitle( e.target.value )}
required
/>
</div>
<div className="form-group">
<label htmlFor="body">本文</label>
<textarea
id="body"
value={body}
onChange={e => setBody( e.target.value )}
required
/>
</div>
{createPost.isError && (
<p className="error" role="alert">{createPost.error.message}</p>
)}
<button
type="submit"
disabled={createPost.isPending}
className="btn btn--primary"
>
{createPost.isPending ? '作成中...' : '投稿する'}
</button>
</form>
);
}
// src/components/PostCard.tsx(削除ボタン付き)
import { useDeletePost } from '@/hooks/mutations/usePostMutations';
function PostCard( { post }: { post: Post } ) {
const deletePost = useDeletePost();
return (
<article className="post-card">
<h2>{post.title}</h2>
<p>{post.body}</p>
<button
onClick={() => {
if ( confirm( 'この投稿を削除しますか?' ) ) {
deletePost.mutate( post.id );
}
}}
disabled={deletePost.isPending}
className="btn btn--danger btn--sm"
>
{deletePost.isPending ? '削除中...' : '削除'}
</button>
</article>
);
}
6. キャッシュの手動操作
6.1 よく使うキャッシュ操作
import { useQueryClient } from '@tanstack/react-query';
import { postKeys } from '@/hooks/queries/usePostsQuery';
function CacheControls() {
const queryClient = useQueryClient();
return (
<div>
{/* 特定のクエリを無効化して次回アクセス時に再取得させる */}
<button onClick={() => queryClient.invalidateQueries( {
queryKey: postKeys.all()
} )}>
投稿データを更新する
</button>
{/* 特定のクエリのキャッシュをすぐに削除する */}
<button onClick={() => queryClient.removeQueries( {
queryKey: postKeys.all()
} )}>
キャッシュを削除する
</button>
{/* キャッシュのデータを直接読み取る */}
<button onClick={() => {
const posts = queryClient.getQueryData( postKeys.lists() );
console.log( '現在のキャッシュ:', posts );
}}>
キャッシュを確認する
</button>
{/* キャッシュのデータを手動で設定する(楽観的更新などに使う) */}
<button onClick={() => {
queryClient.setQueryData( postKeys.detail( 1 ), {
id: 1,
title: '手動で設定したタイトル',
body: '手動で設定した本文',
userId: 1,
} );
}}>
キャッシュを手動設定する
</button>
</div>
);
}
7. TanStack RouterとTanStack Queryの連携
7.1 loaderでqueryClientを使ってプリフェッチする
// src/routes/blog/index.tsx
import { createFileRoute } from '@tanstack/react-router';
import { usePostsQuery, postKeys } from '@/hooks/queries/usePostsQuery';
export const Route = createFileRoute( '/blog/' )( {
// ページ表示前にデータをプリフェッチする
loader: async ( { context } ) => {
await context.queryClient.ensureQueryData( {
queryKey: postKeys.list( 1 ),
queryFn: fetchPosts,
} );
},
component: BlogIndexPage,
} );
function BlogIndexPage() {
// loaderでプリフェッチ済みなのでisLoadingはfalseになる
const { data: posts } = usePostsQuery( 1 );
return (
<div className="posts-grid">
{posts?.map( post => (
<PostCard key={post.id} post={post} />
) )}
</div>
);
}
8. 実践チェックリスト
✓ QueryClientProvider でアプリ全体をラップしているか
✓ staleTime と gcTime をプロジェクトの要件に合わせて設定しているか
✓ queryKey を定数ファクトリ(postKeys など)で管理しているか
✓ isLoading でスケルトンUI・isFetching でバックグラウンド更新インジケーターを表示しているか
✓ APIリクエスト関数(fetcher)を useQuery の外に定義しているか
✓ カスタムフックでクエリのロジックをコンポーネントから分離しているか
✓ useMutation の onSuccess でキャッシュを無効化して最新データを反映しているか
✓ 楽観的更新を使う場合は onError でロールバック処理を書いているか
✓ ReactQueryDevToolsで開発環境のキャッシュ状態を確認しているか
まとめ
今回はTanStack QueryでAPIデータの取得・キャッシュ・ミューテーションを管理する方法を解説しました。ポイントをまとめると:
- TanStack QueryはAPIデータの取得・キャッシュ・再取得・エラーリトライを自動で管理してくれる
QueryClientProviderでアプリをラップしてstaleTime・gcTime・retryなどのデフォルト設定を定義するuseQueryでデータを取得してqueryKeyでキャッシュを識別するisLoadingは初回ローディング・isFetchingはバックグラウンド更新中を表すqueryKeyはpostKeysのようなファクトリ関数で一元管理するとinvalidateQueriesが使いやすくなるuseMutationでデータの作成・更新・削除を行いonSuccessでキャッシュを無効化する- 楽観的更新は
onMutateでキャッシュを先に更新してonErrorでロールバックする useInfiniteQueryで無限スクロールを実装できる
次の記事では、ReactアプリにフォームライブラリReact Hook FormとZodを組み合わせてバリデーション付きフォームを効率よく実装する方法を解説します。お楽しみに!
コメント