TanStack QueryでAPIデータを管理する|キャッシュ・リフレッシュ・ミューテーションの実践的な実装


はじめに

前回の記事では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>
);

💡 staleTimegcTime の違いを理解しましょう:

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>
    );
}

💡 isLoadingisFetching の違いに注意しましょう:

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"
                >
                    &laquo; 前のページ
                </button>
                <span>ページ {page}</span>
                <button
                    onClick={() => setPage( p => p + 1 )}
                    // 前のデータを表示中のときは「次へ」を無効にしない
                    disabled={isPlaceholderData || ( data?.length ?? 0 ) < 10}
                    className="btn btn--outline"
                >
                    次のページ &raquo;
                </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 でアプリ全体をラップしているか

staleTimegcTime をプロジェクトの要件に合わせて設定しているか

queryKey を定数ファクトリ(postKeys など)で管理しているか

isLoading でスケルトンUI・isFetching でバックグラウンド更新インジケーターを表示しているか

APIリクエスト関数(fetcher)を useQuery の外に定義しているか

カスタムフックでクエリのロジックをコンポーネントから分離しているか

useMutationonSuccess でキャッシュを無効化して最新データを反映しているか

楽観的更新を使う場合は onError でロールバック処理を書いているか

ReactQueryDevToolsで開発環境のキャッシュ状態を確認しているか


まとめ

今回はTanStack QueryでAPIデータの取得・キャッシュ・ミューテーションを管理する方法を解説しました。ポイントをまとめると:

  • TanStack QueryはAPIデータの取得・キャッシュ・再取得・エラーリトライを自動で管理してくれる
  • QueryClientProvider でアプリをラップして staleTimegcTimeretry などのデフォルト設定を定義する
  • useQuery でデータを取得して queryKey でキャッシュを識別する
  • isLoading は初回ローディング・isFetching はバックグラウンド更新中を表す
  • queryKeypostKeys のようなファクトリ関数で一元管理すると invalidateQueries が使いやすくなる
  • useMutation でデータの作成・更新・削除を行い onSuccess でキャッシュを無効化する
  • 楽観的更新は onMutate でキャッシュを先に更新して onError でロールバックする
  • useInfiniteQuery で無限スクロールを実装できる

次の記事では、ReactアプリにフォームライブラリReact Hook FormとZodを組み合わせてバリデーション付きフォームを効率よく実装する方法を解説します。お楽しみに!

コメント

タイトルとURLをコピーしました