はじめに
ポートフォリオとして開発中の人気投票Webアプリに、無限スクロール機能を持った一覧ページを実装しました。
構成としては、Server Component で1ページ目を prefetch し、hydration を通じて useInfiniteQuery にキャッシュを引き継ぐ形になっています。
これにより、初回表示はサーバーで事前取得済みのデータが即座に表示され、2ページ目以降はクライアントでシームレスに追加取得されます。
本記事では、この構成を以下の流れで解説します。
- カーソルベースページネーションのAPI設計(サーバー側)
- useInfiniteQuery によるページデータ管理(クライアント側)
- IntersectionObserver によるスクロール検知(クライアント側)
- Server Component での prefetch と hydration
- フィルター条件と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 を内部で利用する形で実装されています。queryKey や queryFn が自動生成されるため、開発者が意識するのは input とgetNextPageParam だけで済みます。
※新しい TanStack Query Integration では queryOptions を生成する形に変わっていますが、現在 create-t3-app では従来の wrapper API が採用されています。
第一引数の input は、サーバーの getInfinite に渡される値です。
同時に、この値は queryKey の一部としても利用されます。
概念的には、内部では次のような queryKey が生成されています。
["poll.getInfinite", { limit: 9, search, tags, status, sortBy }]
ここで重要なのが、queryKey に input が含まれていることです。
例えば、search や tags などの条件が変わると queryKey も変わり、TanStack Query はそれを「別のクエリ」として扱います。その結果、ページネーションの状態も自動的にリセットされ、1ページ目から再取得が行われます。
getNextPageParam の役割
第二引数のオプションで渡している getNextPageParam が、無限スクロールの要となるコールバックです。役割を一言で説明するなら「次のページを取得するための目印を決める」ことです。
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined
サーバー側で hasMore を判定し、nextCursor を返していた処理と、この部分が連携しています。
-
lastPage: 直前に取得したページのレスポンス - 戻り値が
undefined以外→hasNextPage = true(まだ続きがある) - 戻り値が
undefined→hasNextPage = 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 でスクロール検知する
useInfiniteQuery は fetchNextPage で次ページを取得できますが、「いつ呼ぶか」は自分で決める必要があります。
ここでは 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 が返してくれる hasNextPage や isFetchingNextPage状態をガード条件に使っていることです。
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 が変化し、useInfiniteQuery の input が変わります。
セクション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 → クライアントの getNextPageParam → input.cursor → 次のリクエスト、という一連の流れが無限スクロールの中心にあり、そこに prefetch による初回表示の高速化と、URL によるフィルター連携が加わる構成です。
実装を通して、カーソルベースページネーションの設計や、TanStack Query の内部的な仕組み、prefetch + hydration の連携について理解を深めることができました。