【TypeScript × TanStack Query v5】完全ガイド|useQuery・useMutation・型安全なカスタムフック・Zod連携まで徹底解説

【TypeScript × TanStack Query v5】完全ガイド|useQuery・useMutation・型安全なカスタムフック・Zod連携まで徹底解説 TypeScript

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 成功前はundefinedisSuccessで絞り込むとTになる
Suspenseサポート useSuspenseQueryを使うとdataが常にT(undefinedなし)
@tanstack/eslint-plugin ESLintプラグインで型安全でないクエリキー等を検出できる
v4からの主な変更点

  • onSuccess/onError/onSettledコールバックがuseQueryから削除された(useMutationには残存)
  • status: "loading"status: "pending"に変更
  • isLoadingisPending && isFetching相当に変更
  • cacheTimegcTimeに改名
  • インポート元がreact-queryから@tanstack/react-queryに統一(v4から)

インストールとセットアップ

ターミナル
npm install @tanstack/react-query

# DevToolsを使う場合
npm install @tanstack/react-query-devtools
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: 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が推論されるため、多くの場合型パラメータを明示する必要はありません。

TypeScript(型シグネチャ)
// useQuery の型シグネチャ(簡略版)
function useQuery<
  TQueryFnData,  // queryFn が返すデータの型
  TError,        // エラーの型(デフォルト: Error)
  TData,         // select で変換後のデータの型(デフォルト: TQueryFnData)
  TQueryKey      // クエリキーの型
>(options: UseQueryOptions<...>): UseQueryResult<TData, TError>;

基本的な使い方

TypeScript
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>;
}
isSuccess で data を絞り込む
dataの型はUser | undefinedです。if (isSuccess)ブロック内では TypeScript がdata: Userに絞り込みます。data!(Non-null assertion)は使わず、isSuccessif (!data)で絞り込んでください。

selectオプションでデータを変換する

selectオプションを使うと、サーバーから取得したデータをコンポーネントに渡す前に変換できます。APIレスポンスの形とUIで使いたい形が異なる場合に便利です。

TypeScript
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オプションで条件付きフェッチ

TypeScript
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 に渡す引数の型)です。

TypeScript
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型パラメータを使います。

TypeScript
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が必須になり、型定義がより明確になりました。

TypeScript
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ごとにカスタムフックを作ると、クエリキーと型が一箇所に集まりメンテナンスしやすくなります。クエリキーの管理も型安全にする方法も紹介します。

クエリキーファクトリー

src/queries/userQueries.ts
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によるバリデーションが効果的です。

src/schemas/userSchema.ts
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(),
});
src/queries/userQueries.ts(Zodでバリデーション)
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ではdataT | undefinedですが、useSuspenseQueryを使うとSuspenseと連携してdataが常にT(undefinedなし)になります。

TypeScript
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のグローバルエラーハンドリング

すべてのクエリで共通のエラーハンドリングを行いたい場合は、QueryClientonErrorを使います。

TypeScript
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
src/test/handlers.ts
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 });
  }),
];
src/test/setup.ts
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 };
src/components/__tests__/UserProfile.test.tsx
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
// NG: data は User | undefined なのに直接アクセスしている
const { data } = useUser(1);
console.log(data.name); // Object is possibly undefined
OK
// 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

TypeScript
// 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では削除)
// NG: v5 では useQuery の onSuccess は削除された
const { data } = useQuery({
  queryKey: ["user", 1],
  queryFn: fetchUser,
  onSuccess: (data) => { /* エラー: 型に存在しない */ },
});
OK(v5の代替)
// 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 | undefinedisSuccessで絞り込むかuseSuspenseQueryを使う
エラー型 v5からデフォルトがError。カスタムエラーはTErrorで指定
クエリキー ファクトリーパターンで一元管理。as constでリテラル型にする
楽観的更新 onMutate/onError/onSettledTContext型パラメータを活用
Zod連携 queryFn内でZodバリデーションを実行し、実行時の型安全を確保
テスト テストごとに新しいQueryClientを作成。mswでAPIをモック
v5の注意点 onSuccess/onErroruseQueryから削除。status: "loading""pending"に変更

TanStack QueryはTypeScriptとの相性が非常に良く、正しく設定すれば型推論だけで大部分の型が決まります。クエリキーファクトリーパターンとZodを組み合わせることで、フロントエンドのデータフェッチングを型安全・堅牢に実装できます。

Axiosとの組み合わせでqueryFnを実装する場合はTypeScript × Axios 型安全なHTTPクライアント完全ガイドも参考にしてください。