レビュー済み·難易度: 中級·更新: 2026-04-17

TanStack Query の useInfiniteQuery で無限スクロールを実装する

useInfiniteQuery を使い、スクロールに応じて次ページを自動フェッチする無限スクロールの実装例。Intersection Observer と組み合わせる。

nextjsapipaginationtanstack-query

対応バージョン

nextjs 15react 19tanstack-query 5

前提環境

TanStack Query の useQuery と QueryClientProvider の基本を理解していること(tanstack-query-data-fetching を参照)

概要

useInfiniteQuery を使うと、ページネーション API の結果を累積しながら取得できる。 Intersection Observer と組み合わせることで、リストの末尾が表示されたタイミングで自動的に次ページをフェッチする無限スクロールを実現できる。

インストール

npm install @tanstack/react-query

実装例

// src/components/InfinitePostList.tsx
"use client";

import { useInfiniteQuery } from "@tanstack/react-query";
import { useEffect, useRef } from "react";

type Post = { id: number; title: string; body: string };

type PageData = {
  posts: Post[];
  nextPage: number | undefined;
};

async function fetchPosts({ pageParam = 1 }: { pageParam: number }): Promise<PageData> {
  const limit = 10;
  const res = await fetch(
    `https://jsonplaceholder.typicode.com/posts?_page=${pageParam}&_limit=${limit}`
  );
  const posts: Post[] = await res.json();

  return {
    posts,
    nextPage: posts.length === limit ? pageParam + 1 : undefined,
  };
}

export function InfinitePostList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
  } = useInfiniteQuery({
    queryKey: ["posts", "infinite"],
    queryFn: fetchPosts,
    initialPageParam: 1,
    getNextPageParam: (lastPage) => lastPage.nextPage,
  });

  // リスト末尾の要素を監視する ref
  const loadMoreRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const el = loadMoreRef.current;
    if (!el) return;

    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
          fetchNextPage();
        }
      },
      { threshold: 0.1 }
    );

    observer.observe(el);
    return () => observer.disconnect(); // cleanup
  }, [fetchNextPage, hasNextPage, isFetchingNextPage]);

  if (isLoading) {
    return <p className="text-sm text-gray-400">読み込み中...</p>;
  }

  const allPosts = data?.pages.flatMap((page) => page.posts) ?? [];

  return (
    <div className="space-y-3">
      {allPosts.map((post) => (
        <div key={post.id} className="rounded border px-4 py-3 text-sm">
          <p className="font-medium">{post.title}</p>
          <p className="text-gray-500">{post.body}</p>
        </div>
      ))}

      {/* この要素が画面内に入ると次ページをフェッチする */}
      <div ref={loadMoreRef} className="py-2 text-center text-sm text-gray-400">
        {isFetchingNextPage
          ? "読み込み中..."
          : hasNextPage
          ? "スクロールして続きを読む"
          : "これ以上ありません"}
      </div>
    </div>
  );
}

ポイント

  • getNextPageParamundefined を返すと hasNextPagefalse になり fetchNextPage が無効化される
  • data.pages.flatMap(page => page.posts) で全ページの結果を1つの配列にフラット化する
  • IntersectionObserveruseEffect の return で disconnect() して必ずクリーンアップする
  • isFetchingNextPage で追加フェッチ中の状態を管理し、二重リクエストを防ぐ
  • initialPageParam は TanStack Query v5 から必須(v4 では defaultPageParam はなく初期値が undefined

注意点

useInfiniteQuery の getNextPageParam で次ページのカーソルを返す。undefined を返すと fetchNextPage が無効化される。Intersection Observer の cleanup は useEffect の return で行うこと。

関連サンプル