はじめに

Reactアプリケーションにおいて、APIからのデータ取得やサーバー状態の管理は避けて通れない課題です。useEffectuseStateを組み合わせた従来の方法では、ローディング状態の管理、エラーハンドリング、キャッシュ、再取得ロジックなど、多くのボイラープレートコードが必要になります。

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の開閉状態、フォームの入力値、選択されたタブなど、クライアント側で完結する状態です。useStateuseReducer、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>
  );
}

期待される結果

本記事の内容を実践することで、以下の成果が期待できます。

  1. コード量の削減: useEffectuseStateを組み合わせた従来の実装と比較して、データ取得ロジックのコード量が50%以上削減されます。

  2. パフォーマンスの向上: 自動キャッシュにより、同じデータへの重複リクエストが排除され、ネットワーク負荷とレンダリング回数が大幅に減少します。

  3. ユーザー体験の改善: 楽観的更新により、ユーザーの操作に対する応答が即座に行われ、体感速度が向上します。

  4. 保守性の向上: Query Factoryパターンにより、APIロジックが一元管理され、変更時の影響範囲が明確になります。

  5. デバッグ効率の向上: DevToolsを活用することで、キャッシュの状態やクエリの実行状況を視覚的に確認でき、問題の特定が容易になります。

まとめ

TanStack Queryは、Reactアプリケーションにおけるサーバー状態管理の複雑さを大幅に軽減するライブラリです。本記事で解説した内容を振り返ります。

  • useQueryによるデータ取得と自動的な状態管理
  • useMutationによるデータ更新とキャッシュの無効化
  • staleTimegcTimeを活用したキャッシュ戦略
  • 楽観的更新によるUXの向上
  • useInfiniteQueryによる無限スクロールの実装
  • 実務で活用できるQuery Factoryパターン

従来のuseEffectベースのデータ取得から移行することで、より宣言的で保守性の高いコードを書くことができます。まずは既存プロジェクトの一部分から導入を始め、徐々に適用範囲を広げていくことをお勧めします。

参考リンク