1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Next.js + tRPC】 prefetch + useInfiniteQuery で作る無限スクロール

1
Last updated at Posted at 2026-03-06

はじめに

ポートフォリオとして開発中の人気投票Webアプリに、無限スクロール機能を持った一覧ページを実装しました。

構成としては、Server Component で1ページ目を prefetch し、hydration を通じて useInfiniteQuery にキャッシュを引き継ぐ形になっています。
これにより、初回表示はサーバーで事前取得済みのデータが即座に表示され、2ページ目以降はクライアントでシームレスに追加取得されます。

本記事では、この構成を以下の流れで解説します。

  1. カーソルベースページネーションのAPI設計(サーバー側)
  2. useInfiniteQuery によるページデータ管理(クライアント側)
  3. IntersectionObserver によるスクロール検知(クライアント側)
  4. Server Component での prefetch と hydration
  5. フィルター条件とURLの同期

使用技術

レイヤー 技術
フレームワーク Next.js 15(App Router)
API tRPC v11
データフェッチ TanStack Query
ORM Prisma

なお、本アプリは create-t3-app(T3 Stack)で構築しており、tRPC が TanStack Query をラップした形の hooks を利用しています。


1. サーバー側: カーソルベースページネーションのAPI設計

無限スクロールを実現するには、サーバー側に「一部のデータを返し、続きがあるかどうかも伝える」APIが必要です。

オフセット vs カーソル

ページネーションAPIの実装方式は主に2つあります。

オフセット方式OFFSET 20 LIMIT 10(21番目から10件取得)のように、位置を数値で指定します。実装はシンプルですが、データが追加・削除されると「2ページ目を読んでいる間に新しいデータが追加されて、同じデータが再度表示される」問題が起きます。

カーソル方式は「このIDの次から10件取得」のように、特定のレコードを起点に、その並び順に従って次のデータを取得する方式です。
今回は、より安定した無限スクロールを実現するため、カーソル方式を採用しました。

tRPC の input 定義

// src/server/api/routers/poll.ts

getInfinite: publicProcedure
  .input(
    z.object({
      limit: z
        .number()
        .min(1)
        .max(MAX_PAGE_SIZE)
        .default(INFINITE_SCROLL_PAGE_SIZE),
      cursor: z.string().optional(),
      search: z.string().optional(),
      tags: z.array(z.string()).optional(),
      status: z
        .enum(["active", "ended", "upcoming", "all"])
        .default("active"),
      sortBy: z
        .enum(["newest", "ending_soon", "most_votes"])
        .default("newest"),
    }),
  )

ここでは cursor フィールドが重要で、初回リクエストでは undefined になります。
2回目以降は、前回レスポンスの nextCursor が TanStack Query によって自動的に cursor として渡されます(セクション2で詳しく解説します)。

Prisma でのデータ取得

// src/server/api/routers/poll.ts
const polls = await ctx.db.poll.findMany({
  where,
  take: limit + 1, // 9件欲しいなら10件取得
  orderBy: /* ... */,
  include: { /* ... */ },
});

// 10件取れた → まだ次がある
const hasMore = polls.length > limit;
// クライアントに返すのは要求された9件だけなので取り除く
const pollsToReturn = hasMore ? polls.slice(0, limit) : polls;

次のページがあるかを判定するために、要求数 + 1件を取得します。
この方法なら、データベースに「全部で何件あるか」を別途問い合わせる COUNT クエリが不要です。limit + 1 件取得して、余った1件の有無だけで判定ができます。

getInfinite のレスポンス

// src/types/poll.ts

export type InfinitePollListResponse = {
  polls: PollListItem[];     // 今回のページのデータ
  nextCursor: string | null; // 次のページの起点(なければnull)
};
// src/server/api/routers/poll.ts
const nextCursor = hasMore
  ? pollsToReturn[pollsToReturn.length - 1]!.id
  : null;

return {
  polls: transformedPolls,
  nextCursor,
};

nextCursor にはクライアントに返したデータの最後のIDをセットします。
クライアント側では、ここで返却された nextCursor をもとに TanStack Query が自動的に cursor を設定してくれるため、開発者がページ番号や件数を管理する必要はありません。


2. クライアント側: useInfiniteQuery でページデータの管理

ここからはクライアント側の実装です。

無限スクロールでは、

  • 次のページを取得する
  • 取得したページを画面に追加する
  • まだ続きがあるかを判定する

といった状態管理が必要になります。
TanStack Query の useInfiniteQuery を使うと、これらをすべてフックが管理してくれるため、無限スクロールのロジックをシンプルに実装できます。

useInfiniteQuery のデータ構造

通常の useQuery:
  data = { polls: [...9件] }

useInfiniteQuery:
  data.pages = [
    { polls: [...9件], nextCursor: "id-9" },    ← 1ページ目
    { polls: [...9件], nextCursor: "id-18" },   ← 2ページ目
    { polls: [...5件], nextCursor: null },       ← 3ページ目(最後)
  ]

通常の useQuery は、1回のリクエストで取得したデータを1つのキャッシュとして管理します。
一方 useInfiniteQuery は、ページごとのレスポンスを配列として保持するデータ構造を持っています。

useInfiniteQuery の呼び出し

// src/components/polls-list.tsx

const {
  data,               // 取得済みの全ページデータ
  fetchNextPage,      // 次のページを取得する関数
  hasNextPage,        // 次のページがあるか(boolean)
  isFetchingNextPage, // 次のページを取得中か(boolean)
  isLoading,          // 初回ロード中か(boolean)
  isError,            // エラーか(boolean)
} = api.poll.getInfinite.useInfiniteQuery(
  {
    limit: 9,
    search,
    tags,
    status,
    sortBy,
  },
  {
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
  },
);

T3 Stack の tRPC クライアントは、TanStack Query を内部で利用する形で実装されています。queryKeyqueryFn が自動生成されるため、開発者が意識するのは inputgetNextPageParam だけで済みます。

※新しい TanStack Query Integration では queryOptions を生成する形に変わっていますが、現在 create-t3-app では従来の wrapper API が採用されています。

第一引数の input は、サーバーの getInfinite に渡される値です。
同時に、この値は queryKey の一部としても利用されます。
概念的には、内部では次のような queryKey が生成されています。

["poll.getInfinite", { limit: 9, search, tags, status, sortBy }]

ここで重要なのが、queryKeyinput が含まれていることです。
例えば、searchtags などの条件が変わると queryKey も変わり、TanStack Query はそれを「別のクエリ」として扱います。その結果、ページネーションの状態も自動的にリセットされ、1ページ目から再取得が行われます。

getNextPageParam の役割

第二引数のオプションで渡している getNextPageParam が、無限スクロールの要となるコールバックです。役割を一言で説明するなら「次のページを取得するための目印を決める」ことです。

getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined

サーバー側で hasMore を判定し、nextCursor を返していた処理と、この部分が連携しています。

  • lastPage: 直前に取得したページのレスポンス
  • 戻り値が undefined 以外→ hasNextPage = true(まだ続きがある)
  • 戻り値が undefinedhasNextPage = false(もうデータがない)

hasNextPage は、getNextPageParam の戻り値が undefined かどうかで TanStack Query が自動的に判定しています。
また、getNextPageParam がカーソル値を返した場合、その値は pageParam として扱われ、次のリクエスト時に cursor として input に渡されます。
セクション1で触れた、サーバーの nextCursor がクライアントの cursor に渡る仕組みは、この getNextPageParam を介して実現されています。

1ページ目: input = { limit: 9, cursor: undefined }
                                       ↑ 初回なのでcursorなし
    ↓ レスポンスが返る
    ↓ getNextPageParam → "cm5abc123" を返す
    ↓
2ページ目: input = { limit: 9, cursor: "cm5abc123" }
                                       ↑ 自動でセットされる

3. クライアント側: IntersectionObserver でスクロール検知する

useInfiniteQueryfetchNextPage で次ページを取得できますが、「いつ呼ぶか」は自分で決める必要があります。
ここでは IntersectionObserver(特定の要素が画面内に入った瞬間を検知できるブラウザAPI)を使い、リスト末尾が見えたら自動取得する仕組みを作ります。

// src/components/polls-list.tsx

const loadMoreRef = useRef<HTMLDivElement>(null);

useEffect(() => {
  if (!loadMoreRef.current || !hasNextPage || isFetchingNextPage) return;

  const observer = new IntersectionObserver(
    (entries) => {
      if (entries[0]?.isIntersecting) {
        void fetchNextPage();
      }
    },
    { threshold: 0.1 }, // 要素の10%が画面内に入ったら発火
  );

  observer.observe(loadMoreRef.current);
  return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);

ポイントは、useInfiniteQuery が返してくれる hasNextPageisFetchingNextPage状態をガード条件に使っていることです。

JSXではリスト末尾にトリガー要素を置きます。

{hasNextPage && (
  <div ref={loadMoreRef} className="flex items-center justify-center py-8">
    {isFetchingNextPage && <Loader2 className="h-6 w-6 animate-spin" />}
  </div>
)}

この div が画面内に入ると fetchNextPage() → 再レンダリングで新しいカードが表示 → div が画面外に押し出される → 再びスクロールすると発火、というサイクルが繰り返されます。


4. Server Componentでの初期データ prefetch

ここまでの実装だけでも無限スクロールは動作しますが、初回表示時にこうなります:

1. サーバーがHTMLを返す(投票一覧データはまだ取得していない)
2. ブラウザでJSが実行される
3. useInfiniteQuery が API を呼ぶ
4. ローディングスピナーが表示される
5. データが返ってきて投票カードが表示される

ステップ3〜5の間、ユーザーは情報を見ることができません。これを解決するのが Server Component での prefetch です。

prefetchInfinite + HydrateClient

// src/app/polls/page.tsx(Server Component)

  // サーバーで1ページ目のデータを事前取得
  await api.poll.getInfinite.prefetchInfinite(
    { limit: 9, search, tags, status, sortBy },
    {
      pages: 1, // 1ページ目を取得
      getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
    },
  );

prefetchInfinite は、Server Component 上で useInfiniteQuery と同じクエリを事前実行し、結果をサーバー側の QueryClient にキャッシュするメソッドです。
ここで 1ページ目のデータが取得され、サーバー側のキャッシュに保存されます。

  return (
    <HydrateClient>
      <PollFilters />
      <PollsList />
    </HydrateClient>
  );

HydrateClient は、サーバー側で取得した TanStack Query のキャッシュを、ブラウザ側の QueryClient に引き継ぐためのコンポーネントです。

これにより クライアント側の QueryClient も同じキャッシュを持った状態で起動するため、初回の API 呼び出しとスピナー表示をスキップできます。

prefetchなし: HTML → JS実行 → API呼び出し → スピナー → 表示
prefetchあり: HTML(データ入り) → JS実行 → キャッシュから即表示

5. フィルター連携: URLSearchParamsとの同期

フィルター条件は URL のクエリパラメータで管理します。
フィルターコンポーネント(PollFilters)がURLを更新すると、一覧コンポーネント(PollsList)側の useSearchParams が変化し、useInfiniteQueryinput が変わります。
セクション2で触れたように、input が変わると queryKey も変わるため、TanStack Query はそれを別のクエリとして扱い、1ページ目から再取得します。

PollFilters: ステータスを「終了済み」に変更
  ↓
router.push("/polls?status=ended")
  ↓
PollsList: useSearchParams() が更新される
  ↓
useInfiniteQuery の input が { status: "ended" } に変わる
  ↓
TanStack Query がデータをリセット → 1ページ目を再取得

コンポーネント間で直接 props を受け渡したり、グローバルな状態管理を使ったりする必要がなく、URLが唯一の信頼できる情報源(Single Source of Truth) として機能しています。


まとめ

本記事では、Next.js App Router + tRPC 環境での無限スクロール実装を解説しました。

サーバーの nextCursor → クライアントの getNextPageParaminput.cursor → 次のリクエスト、という一連の流れが無限スクロールの中心にあり、そこに prefetch による初回表示の高速化と、URL によるフィルター連携が加わる構成です。

実装を通して、カーソルベースページネーションの設計や、TanStack Query の内部的な仕組み、prefetch + hydration の連携について理解を深めることができました。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?