Reactでデータフェッチングを実装するとき、ローディング状態・エラー処理・キャッシュ・再フェッチを自前で管理するのは手間がかかります。TanStack Query(旧React Query)はこれらを自動的に扱ってくれるライブラリです。
特にv5からはTypeScriptとの統合がさらに強化され、型推論だけでほぼすべての型が決まる設計になりました。本記事ではTanStack Query v5をTypeScriptで使い倒すための知識を体系的に解説します。
- TanStack Query v5がTypeScriptと相性が良い理由・v4からの変更点
useQuery<TData, TError>の型パラメータと型推論の仕組みqueryFnの戻り値型を型安全に定義する方法useMutationの変数型・コールバック型の定義パターンuseInfiniteQueryの型定義- APIごとに型安全なカスタムフックを設計する実務パターン
- Zodでレスポンスを実行時検証しながら型を付ける方法
- テスト(msw・QueryClientのモック)
非同期処理の型定義全般についてはTypeScript 非同期処理の型定義 完全ガイドを、カスタムフックの基礎についてはTypeScript × React Hooks の型定義完全ガイドもあわせてご覧ください。
TanStack QueryとTypeScriptの相性
TanStack Query v5はTypeScriptで書かれており、型定義が本体に含まれています。
| 特徴 | 内容 |
|---|---|
| 型推論が強力 | queryFnの戻り値型からdataの型が自動的に決まる |
エラー型がError |
v5からTErrorのデフォルトがErrorに統一。型アサーション不要になった |
dataは常にT | undefined |
成功前はundefined。isSuccessで絞り込むとTになる |
| Suspenseサポート | useSuspenseQueryを使うとdataが常にT(undefinedなし) |
| @tanstack/eslint-plugin | ESLintプラグインで型安全でないクエリキー等を検出できる |
onSuccess/onError/onSettledコールバックがuseQueryから削除された(useMutationには残存)status: "loading"がstatus: "pending"に変更isLoadingがisPending && isFetching相当に変更cacheTimeがgcTimeに改名- インポート元が
react-queryから@tanstack/react-queryに統一(v4から)
インストールとセットアップ
npm install @tanstack/react-query # DevToolsを使う場合 npm install @tanstack/react-query-devtools
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: 1000 * 60 * 5, // 5分間はキャッシュを新鮮とみなす
retry: 2, // 失敗時に2回リトライ
refetchOnWindowFocus: false, // ウィンドウフォーカス時の再フェッチを無効化
},
},
});
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</React.StrictMode>
);
useQueryの型定義
useQueryの型シグネチャは以下の通りです。queryFnの戻り値型からTDataが推論されるため、多くの場合型パラメータを明示する必要はありません。
// useQuery の型シグネチャ(簡略版) function useQuery< TQueryFnData, // queryFn が返すデータの型 TError, // エラーの型(デフォルト: Error) TData, // select で変換後のデータの型(デフォルト: TQueryFnData) TQueryKey // クエリキーの型 >(options: UseQueryOptions<...>): UseQueryResult<TData, TError>;
基本的な使い方
import { useQuery } from "@tanstack/react-query";
interface User {
id: number;
name: string;
email: string;
}
async function fetchUser(id: number): Promise<User> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
function UserProfile({ userId }: { userId: number }) {
// queryFn の戻り値 Promise<User> から data: User | undefined が推論される
const { data, isLoading, isError, error } = useQuery({
queryKey: ["user", userId],
queryFn: () => fetchUser(userId),
});
if (isLoading) return <p>読み込み中...</p>;
if (isError) return <p>エラー: {error.message}</p>;
if (!data) return null;
return <p>{data.name}({data.email})</p>;
}
dataの型はUser | undefinedです。if (isSuccess)ブロック内では TypeScript がdata: Userに絞り込みます。data!(Non-null assertion)は使わず、isSuccessやif (!data)で絞り込んでください。selectオプションでデータを変換する
selectオプションを使うと、サーバーから取得したデータをコンポーネントに渡す前に変換できます。APIレスポンスの形とUIで使いたい形が異なる場合に便利です。
interface ApiUser {
user_id: number;
full_name: string;
email_address: string;
}
interface User {
id: number;
name: string;
email: string;
}
function useUserQuery(userId: number) {
return useQuery({
queryKey: ["user", userId],
queryFn: async () => {
const res = await fetch(`/api/users/${userId}`);
return res.json() as Promise<ApiUser>;
},
// select の戻り値型が data の型になる → User 型が推論される
select: (apiUser): User => ({
id: apiUser.user_id,
name: apiUser.full_name,
email: apiUser.email_address,
}),
});
// 戻り値: UseQueryResult<User, Error>
}
enabledオプションで条件付きフェッチ
function useUserDetails(userId: number | null) {
return useQuery({
queryKey: ["user", userId],
queryFn: () => fetchUser(userId!),
// userId が null の場合はフェッチしない
enabled: userId !== null,
});
}
// enabled: false の場合、status は "pending" だが isFetching は false
// このためローディングスピナーを出すには isPending && isFetching で判定する
const { data, isPending, isFetching } = useUserDetails(null);
const isLoading = isPending && isFetching; // 実際にフェッチ中かどうか
useMutationの型定義
useMutationは<TData, TError, TVariables, TContext>の4つの型パラメータを持ちます。最もよく使うのはTData(成功時のレスポンス型)とTVariables(mutate に渡す引数の型)です。
import { useMutation, useQueryClient } from "@tanstack/react-query";
interface CreateUserInput {
name: string;
email: string;
}
interface User {
id: number;
name: string;
email: string;
}
async function createUser(input: CreateUserInput): Promise<User> {
const res = await fetch("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}
function CreateUserForm() {
const queryClient = useQueryClient();
// TData=User, TError=Error, TVariables=CreateUserInput
const mutation = useMutation({
mutationFn: createUser,
onSuccess: (data) => {
// data は User 型として推論される
console.log("作成成功:", data.name);
// ユーザー一覧のキャッシュを無効化して再フェッチを促す
queryClient.invalidateQueries({ queryKey: ["users"] });
},
onError: (error) => {
// error は Error 型として推論される(v5のデフォルト)
console.error("作成失敗:", error.message);
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
mutation.mutate({
name: (form.elements.namedItem("name") as HTMLInputElement).value,
email: (form.elements.namedItem("email") as HTMLInputElement).value,
});
};
return (
<form onSubmit={handleSubmit}>
<input name="name" />
<input name="email" type="email" />
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? "送信中..." : "作成"}
</button>
{mutation.isError && <p>エラー: {mutation.error.message}</p>}
</form>
);
}
楽観的更新(Optimistic Update)
楽観的更新はAPIのレスポンスを待たずにUIを先に更新し、失敗時にロールバックするパターンです。onMutate/onError/onSettledコールバックとTContext型パラメータを使います。
interface Todo {
id: number;
text: string;
completed: boolean;
}
function useToggleTodo() {
const queryClient = useQueryClient();
return useMutation<
Todo, // TData: 成功レスポンス
Error, // TError: エラー型
number, // TVariables: mutate に渡す引数(todoのid)
{ previousTodos: Todo[] | undefined } // TContext: onMutate の戻り値
>({
mutationFn: async (id) => {
const res = await fetch(`/api/todos/${id}/toggle`, { method: "PATCH" });
return res.json();
},
onMutate: async (id) => {
// フェッチ中の再フェッチをキャンセル
await queryClient.cancelQueries({ queryKey: ["todos"] });
// 現在のキャッシュを保存(ロールバック用)
const previousTodos = queryClient.getQueryData<Todo[]>(["todos"]);
// UIを楽観的に更新
queryClient.setQueryData<Todo[]>(["todos"], (old) =>
old?.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
);
return { previousTodos }; // TContext として返す
},
onError: (_err, _id, context) => {
// エラー時にロールバック
if (context?.previousTodos) {
queryClient.setQueryData(["todos"], context.previousTodos);
}
},
onSettled: () => {
// 成功・失敗に関わらず最終的に再フェッチ
queryClient.invalidateQueries({ queryKey: ["todos"] });
},
});
}
useInfiniteQueryの型定義
無限スクロールや「もっと見る」ボタンの実装にはuseInfiniteQueryを使います。v5ではinitialPageParamが必須になり、型定義がより明確になりました。
import { useInfiniteQuery } from "@tanstack/react-query";
interface Post {
id: number;
title: string;
body: string;
}
interface PostsPage {
posts: Post[];
nextCursor: number | null;
total: number;
}
async function fetchPosts(cursor: number, limit = 10): Promise<PostsPage> {
const res = await fetch(`/api/posts?cursor=${cursor}&limit=${limit}`);
return res.json();
}
function usePostsInfinite() {
return useInfiniteQuery({
queryKey: ["posts", "infinite"],
queryFn: ({ pageParam }) => fetchPosts(pageParam),
initialPageParam: 0, // v5: 初期カーソル値(必須)
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
// undefined を返すと「次のページなし」
});
}
function PostList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = usePostsInfinite();
// data.pages は PostsPage[] 型
const allPosts = data?.pages.flatMap((page) => page.posts) ?? [];
return (
<div>
{allPosts.map((post) => <div key={post.id}>{post.title}</div>)}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage ? "読み込み中..." : "もっと見る"}
</button>
</div>
);
}
型安全なカスタムフック設計パターン
APIごとにカスタムフックを作ると、クエリキーと型が一箇所に集まりメンテナンスしやすくなります。クエリキーの管理も型安全にする方法も紹介します。
クエリキーファクトリー
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
// クエリキーを一元管理するファクトリー
// as const で readonly タプル型にすることでキーが型安全になる
export const userKeys = {
all: ["users"] as const,
lists: () => [...userKeys.all, "list"] as const,
list: (filters: UserListFilters) => [...userKeys.lists(), filters] as const,
details: () => [...userKeys.all, "detail"] as const,
detail: (id: number) => [...userKeys.details(), id] as const,
} as const;
interface UserListFilters {
page?: number;
search?: string;
role?: "admin" | "user";
}
interface UserListResponse {
users: User[];
total: number;
page: number;
}
// ユーザー一覧を取得するカスタムフック
export function useUsers(filters: UserListFilters = {}) {
return useQuery({
queryKey: userKeys.list(filters),
queryFn: () => fetchUserList(filters),
staleTime: 1000 * 30, // 30秒
});
}
// ユーザー詳細を取得するカスタムフック
export function useUser(id: number) {
return useQuery({
queryKey: userKeys.detail(id),
queryFn: () => fetchUser(id),
enabled: id > 0,
});
}
// ユーザー作成ミューテーション
export function useCreateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createUser,
onSuccess: () => {
// userKeys.lists() で始まるすべてのクエリを無効化
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
},
});
}
// ユーザー更新ミューテーション
export function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<User> }) =>
updateUser(id, data),
onSuccess: (_, variables) => {
// 更新したユーザーの詳細キャッシュを無効化
queryClient.invalidateQueries({ queryKey: userKeys.detail(variables.id) });
},
});
}
クエリキーファクトリーパターンを使うと、invalidateQueriesの引数にも型補完が効き、キーの打ち間違いによるキャッシュ無効化漏れを防げます。
Zodと組み合わせてレスポンスを実行時検証する
TypeScriptの型はコンパイル時にしか機能しません。APIのレスポンスが期待通りの型であることを実行時にも保証するには、Zodによるバリデーションが効果的です。
import { z } from "zod";
export const UserSchema = z.object({
id: z.number(),
name: z.string().min(1),
email: z.string().email(),
role: z.enum(["admin", "user", "guest"]),
createdAt: z.string().datetime(),
});
export type User = z.infer<typeof UserSchema>;
export const UserListSchema = z.object({
users: z.array(UserSchema),
total: z.number(),
page: z.number(),
});
import { UserSchema, UserListSchema, User } from "../schemas/userSchema";
async function fetchUser(id: number): Promise<User> {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
// safeParse でバリデーション
const result = UserSchema.safeParse(json);
if (!result.success) {
// バリデーション失敗時はエラーをスロー
throw new Error(
`APIレスポンスの型が不正: ${result.error.message}`
);
}
// result.data は User 型として推論される
return result.data;
}
// useQuery と組み合わせる
export function useUser(id: number) {
return useQuery({
queryKey: ["user", id],
queryFn: () => fetchUser(id),
// queryFn が User を返すので data: User | undefined が自動推論
});
}
Zodの詳しい使い方はTypeScript × Zod 完全ガイドを参照してください。
useSuspenseQueryで data を非nullにする
通常のuseQueryではdataがT | undefinedですが、useSuspenseQueryを使うとSuspenseと連携してdataが常にT(undefinedなし)になります。
import { useSuspenseQuery } from "@tanstack/react-query";
import { Suspense } from "react";
function UserProfile({ userId }: { userId: number }) {
// data は User 型(undefined なし)
const { data: user } = useSuspenseQuery({
queryKey: ["user", userId],
queryFn: () => fetchUser(userId),
});
// isLoading チェック不要。Suspense が解決するまでレンダリングされない
return <p>{user.name}({user.email})</p>;
}
// 親コンポーネントで Suspense と ErrorBoundary でラップ
// ErrorBoundary は react-error-boundary ライブラリを使用
// npm install react-error-boundary
function App() {
return (
<ErrorBoundary fallback={<p>エラーが発生しました</p>}>
<Suspense fallback={<p>読み込み中...</p>}>
<UserProfile userId={1} />
</Suspense>
</ErrorBoundary>
);
}
QueryClientのグローバルエラーハンドリング
すべてのクエリで共通のエラーハンドリングを行いたい場合は、QueryClientのonErrorを使います。
import { QueryCache, MutationCache, QueryClient } from "@tanstack/react-query";
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
// error は Error 型
// 401 エラー時にログインページへリダイレクト
if (
error instanceof Error &&
error.message.includes("401")
) {
window.location.href = "/login";
return;
}
// その他のエラーは toast 通知
console.error(`クエリエラー [${String(query.queryKey)}]:`, error.message);
},
}),
mutationCache: new MutationCache({
onError: (error) => {
console.error("ミューテーションエラー:", error.message);
},
}),
});
エラーハンドリングのパターン全般についてはTypeScript エラーハンドリング完全ガイドも参考にしてください。
テスト
TanStack Queryを使ったコンポーネントのテストには、実際のサーバーへのリクエストをモックするためにmsw(Mock Service Worker)を使うのが定番です。
npm install --save-dev msw @testing-library/react @testing-library/jest-dom vitest jsdom
import { http, HttpResponse } from "msw";
const mockUsers = [
{ id: 1, name: "田中太郎", email: "tanaka@example.com", role: "admin" },
{ id: 2, name: "佐藤花子", email: "sato@example.com", role: "user" },
];
export const handlers = [
http.get("/api/users", () => {
return HttpResponse.json({ users: mockUsers, total: 2, page: 1 });
}),
http.get("/api/users/:id", ({ params }) => {
const user = mockUsers.find((u) => u.id === Number(params.id));
if (!user) return new HttpResponse(null, { status: 404 });
return HttpResponse.json(user);
}),
http.post("/api/users", async ({ request }) => {
const body = await request.json() as { name: string; email: string };
return HttpResponse.json({ id: 99, ...body, role: "user" }, { status: 201 });
}),
];
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
import { afterAll, afterEach, beforeAll } from "vitest";
const server = setupServer(...handlers);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
export { server };
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, it, expect } from "vitest";
import { UserProfile } from "../UserProfile";
// テスト用ラッパー(各テストで新しいQueryClientを作成)
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false }, // テストではリトライしない
},
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
describe("UserProfile", () => {
it("ユーザー情報を表示する", async () => {
render(<UserProfile userId={1} />, { wrapper: createWrapper() });
// ローディング中は「読み込み中」を表示
expect(screen.getByText("読み込み中...")).toBeInTheDocument();
// データ取得後にユーザー名を表示
await waitFor(() => {
expect(screen.getByText(/田中太郎/)).toBeInTheDocument();
});
});
it("404 エラーを表示する", async () => {
render(<UserProfile userId={999} />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText(/エラー/)).toBeInTheDocument();
});
});
});
- 各テストで新しい
QueryClientを作成してキャッシュが混入しないようにする retry: falseを設定してリトライによるテスト時間の増加を防ぐmswでネットワークをモックすることで、実際のHTTPリクエストを送らず高速にテストできる
テスト全般についてはTypeScript Jest・Vitest テスト完全ガイドを参照してください。
よくある型エラーと対処法
型エラー①:data が undefined になる
// NG: data は User | undefined なのに直接アクセスしている
const { data } = useUser(1);
console.log(data.name); // Object is possibly undefined
// OK①: isSuccess で絞り込む
const { data, isSuccess } = useUser(1);
if (isSuccess) console.log(data.name); // data は User 型
// OK②: オプショナルチェーン
console.log(data?.name);
// OK③: useSuspenseQuery を使う(data が T 確定)
const { data } = useSuspenseQuery({ queryKey: ["user", 1], queryFn: () => fetchUser(1) });
console.log(data.name); // User 型(undefined なし)
型エラー②:QueryClient.getQueryData の型が unknown
// getQueryData の戻り値は TQueryFnData | undefined
// ジェネリクスで型を指定する
const user = queryClient.getQueryData<User>(["user", 1]);
// ↑ 型を明示する
// setQueryData も同様
queryClient.setQueryData<User>(["user", 1], (old) => {
if (!old) return old;
return { ...old, name: "新しい名前" };
});
型エラー③:v4のonSuccessコールバックがない
// NG: v5 では useQuery の onSuccess は削除された
const { data } = useQuery({
queryKey: ["user", 1],
queryFn: fetchUser,
onSuccess: (data) => { /* エラー: 型に存在しない */ },
});
// OK①: useEffect で data の変化を監視
const { data } = useQuery({ queryKey: ["user", 1], queryFn: fetchUser });
useEffect(() => {
if (data) {
// data が取得されたときの処理
console.log("取得:", data.name);
}
}, [data]);
// OK②: useMutation の onSuccess は v5 でも使用可能
const mutation = useMutation({
mutationFn: createUser,
onSuccess: (data) => console.log("作成:", data),
});
まとめ
TanStack Query v5をTypeScriptで使う際のポイントをまとめます。
| 項目 | ポイント |
|---|---|
| 型推論 | queryFnの戻り値型からdataの型が自動推論される。型パラメータを手書きする必要はほぼない |
dataの型 |
常にT | undefined。isSuccessで絞り込むかuseSuspenseQueryを使う |
| エラー型 | v5からデフォルトがError。カスタムエラーはTErrorで指定 |
| クエリキー | ファクトリーパターンで一元管理。as constでリテラル型にする |
| 楽観的更新 | onMutate/onError/onSettledとTContext型パラメータを活用 |
| Zod連携 | queryFn内でZodバリデーションを実行し、実行時の型安全を確保 |
| テスト | テストごとに新しいQueryClientを作成。mswでAPIをモック |
| v5の注意点 | onSuccess/onErrorがuseQueryから削除。status: "loading"が"pending"に変更 |
TanStack QueryはTypeScriptとの相性が非常に良く、正しく設定すれば型推論だけで大部分の型が決まります。クエリキーファクトリーパターンとZodを組み合わせることで、フロントエンドのデータフェッチングを型安全・堅牢に実装できます。
Axiosとの組み合わせでqueryFnを実装する場合はTypeScript × Axios 型安全なHTTPクライアント完全ガイドも参考にしてください。
