Next.js でインフィニットスクロールを実装:Route Handlers +`use` で滑らかデータ読込
現代の Web アプリケーションにおいて、インフィニットスクロールは非常に人気の高い UX パターンとなっています。ユーザーがページの最下部に到達すると、自動的に次のコンテンツが読み込まれる仕組みは、Twitter や Instagram など多くのサービスで採用されていますね。
本記事では、Next.js の App Router における Route Handlers と React の use フックを組み合わせて、パフォーマンスに優れたインフィニットスクロールを実装する方法を解説します。段階的にコードを紐解きながら、実践的な実装パターンをお伝えしていきますので、ぜひ最後までお付き合いください。
背景
インフィニットスクロールの需要
従来のページネーション方式では、ユーザーは「次へ」ボタンをクリックして新しいページに移動する必要がありました。しかし、モバイルデバイスの普及により、スクロールだけで次々とコンテンツを閲覧できるインフィニットスクロールが主流になってきています。
Next.js における実装の選択肢
Next.js の App Router では、データ取得の方法として以下の選択肢があります。
| # | 方式 | 特徴 | 適用場面 |
|---|---|---|---|
| 1 | Server Components | サーバーサイドでデータ取得 | 初期ロード時の静的データ |
| 2 | Client Components + fetch | クライアントサイドでデータ取得 | 動的なユーザー操作に応じたデータ |
| 3 | Route Handlers | API ルートとしてデータ提供 | 柔軟なデータ取得、外部からのアクセス |
インフィニットスクロールの場合、スクロール位置に応じて動的にデータを取得する必要があるため、Route Handlers と Client Components を組み合わせるのが最適解となります。
React 19 の use フックの登場
React 19 で導入された use フックは、Promise を直接コンポーネント内で解決できる画期的な機能です。従来の useEffect と useState の組み合わせに比べて、よりシンプルで読みやすいコードが書けるようになりました。
図の意図:従来の実装方法と新しい use フックを使った実装方法の違いを比較します。
mermaidflowchart TB
subgraph old["従来の方法"]
A1["コンポーネント"] -->|fetch 開始| A2["useEffect"]
A2 -->|Promise| A3["await"]
A3 -->|データ| A4["setState"]
A4 -->|再レンダリング| A1
end
subgraph new["use フック"]
B1["コンポーネント"] -->|Promise 渡す| B2["use()"]
B2 -->|データ直接取得| B1
end
図で理解できる要点:
- 従来の方法では複数のステップを経由してデータを取得していました
useフックを使うと、Promise を直接解決してデータを取得できます- コードがシンプルになり、保守性が向上します
課題
従来の実装における問題点
インフィニットスクロールを実装する際、以下のような課題が頻繁に発生します。
1. データ取得のタイミング制御が複雑
従来の useEffect を使った実装では、スクロールイベントの監視、データ取得状態の管理、エラーハンドリングなど、多くの状態を同時に管理する必要がありました。
2. レンダリングのパフォーマンス問題
スクロールイベントは非常に頻繁に発生するため、適切な最適化を行わないとパフォーマンスが低下してしまいます。デバウンスやスロットリングの実装が必須となるのです。
3. 重複リクエストの防止
ユーザーが素早くスクロールした場合、同じデータを複数回リクエストしてしまう可能性があります。これを防ぐためには、ローディング状態の適切な管理が欠かせません。
4. エラーハンドリングの煩雑さ
ネットワークエラーやタイムアウトなど、様々なエラーケースに対応する必要があります。エラー時の UI 表示やリトライロジックの実装は、コードを複雑にする要因となっていました。
図の意図:インフィニットスクロール実装における課題を視覚化します。
mermaidflowchart TD
scroll["スクロールイベント"] -->|頻繁に発生| debounce["デバウンス処理"]
debounce --> check["データ取得判定"]
check -->|ローディング中?| skip["スキップ"]
check -->|OK| fetch["データ取得"]
fetch -->|成功| update["状態更新"]
fetch -->|失敗| error["エラー処理"]
error -->|リトライ?| fetch
update --> render["再レンダリング"]
render -->|パフォーマンス| optimize["最適化必要"]
図で理解できる要点:
- スクロールイベントからデータ表示までに多くのステップが必要です
- 各ステップで適切な制御とエラーハンドリングが求められます
- パフォーマンス最適化を考慮した実装が不可欠です
解決策
Route Handlers と use フックの組み合わせ
Next.js の Route Handlers と React の use フックを組み合わせることで、上記の課題を解決できます。この組み合わせには以下のメリットがあります。
| # | メリット | 詳細 |
|---|---|---|
| 1 | コードの簡潔性 | use フックにより非同期処理がシンプルに記述できる |
| 2 | 型安全性 | TypeScript との相性が良く、型推論が効きやすい |
| 3 | パフォーマンス | Server Components と組み合わせて最適化しやすい |
| 4 | 保守性 | 責務が明確に分離され、テストしやすい構造になる |
アーキテクチャの全体像
実装は以下の 3 つの主要コンポーネントで構成されます。
図の意図:Route Handlers、use フック、Intersection Observer の連携を示します。
mermaidflowchart LR
subgraph client["Client Component"]
observer["Intersection<br/>Observer"] -->|トリガー| hook["use フック"]
hook -->|Promise| api["fetch"]
end
subgraph server["Server"]
route["Route Handler"] -->|データ取得| db[("Database")]
route -->|JSON レスポンス| api
end
api -->|データ| hook
hook -->|表示| ui["UI 更新"]
図で理解できる要点:
- Intersection Observer がスクロール位置を監視します
useフックが Promise を受け取り、データを取得します- Route Handler がサーバーサイドでデータを提供します
実装の基本方針
- Route Handler でデータ取得 API を作成:ページネーション対応のエンドポイントを実装します
- Intersection Observer でスクロール監視:画面下部の要素が表示されたら次のデータを取得します
useフックでデータ解決:Promise を直接コンポーネントで扱い、シンプルな実装を実現します
具体例
ステップ 1:プロジェクトのセットアップ
まず、必要なパッケージをインストールしましょう。TypeScript と Next.js を使用した環境を前提とします。
bashyarn create next-app infinite-scroll-app --typescript
cd infinite-scroll-app
必要な依存関係を追加します。
bashyarn add react@rc react-dom@rc
React 19 の use フックを使用するため、React のリリース候補版をインストールしています。
ステップ 2:データ型の定義
まず、データの型を定義します。この例では、記事(Article)のリストを扱います。
typescript// types/article.ts
export interface Article {
id: number;
title: string;
content: string;
author: string;
createdAt: string;
}
API レスポンスの型も定義しておきましょう。
typescript// types/article.ts
export interface ArticleListResponse {
articles: Article[];
hasMore: boolean;
nextPage: number;
}
hasMore は次のページが存在するかを示し、nextPage は次に取得すべきページ番号を表します。
ステップ 3:Route Handler の作成
Route Handler を使って、ページネーション対応の API エンドポイントを作成します。
typescript// app/api/articles/route.ts
import { NextRequest, NextResponse } from 'next/server';
import {
Article,
ArticleListResponse,
} from '@/types/article';
必要な型とモジュールをインポートします。
Route Handler の本体を実装します。
typescript// app/api/articles/route.ts
export async function GET(request: NextRequest) {
// クエリパラメータからページ番号と件数を取得
const searchParams = request.nextUrl.searchParams;
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '10');
// オフセット計算
const offset = (page - 1) * limit;
クエリパラメータから page と limit を取得し、データベースクエリ用のオフセットを計算しています。デフォルト値として、1 ページ目から 10 件ずつ取得するように設定しました。
次に、実際のデータ取得処理を実装します。この例では、ダミーデータを生成していますが、実際のアプリケーションではデータベースから取得することになります。
typescript// app/api/articles/route.ts
try {
// ダミーデータの生成(実際はDBから取得)
const totalArticles = 100; // 全記事数
const articles: Article[] = Array.from({ length: limit }, (_, i) => ({
id: offset + i + 1,
title: `記事タイトル ${offset + i + 1}`,
content: `これは記事 ${offset + i + 1} の本文です。インフィニットスクロールのデモ用コンテンツとなります。`,
author: `著者 ${(offset + i) % 5 + 1}`,
createdAt: new Date(Date.now() - (offset + i) * 86400000).toISOString(),
})).filter(article => article.id <= totalArticles);
Array.from を使って指定された件数分の記事を生成しています。filter で総記事数を超えないように制限をかけているのがポイントです。
レスポンスデータを構築して返却します。
typescript// app/api/articles/route.ts
// レスポンスデータの構築
const hasMore = offset + limit < totalArticles;
const response: ArticleListResponse = {
articles,
hasMore,
nextPage: hasMore ? page + 1 : page,
};
// 人工的な遅延を追加(ローディング状態を確認するため)
await new Promise(resolve => setTimeout(resolve, 500));
return NextResponse.json(response);
} catch (error) {
return NextResponse.json(
{ error: 'データの取得に失敗しました' },
{ status: 500 }
);
}
}
hasMore は次のページが存在するかを判定し、エラーが発生した場合は適切なエラーレスポンスを返します。開発中にローディング状態を確認しやすくするため、意図的に 500ms の遅延を追加しています。
ステップ 4:カスタムフックの作成
次に、インフィニットスクロールのロジックを再利用可能なカスタムフックとして実装します。
typescript// hooks/useInfiniteScroll.ts
'use client';
import { use, useState, useCallback, useRef } from 'react';
import { ArticleListResponse } from '@/types/article';
クライアントコンポーネントとして動作させるため、'use client' ディレクティブを追加しています。
カスタムフックの型定義とステート管理部分を実装します。
typescript// hooks/useInfiniteScroll.ts
export function useInfiniteScroll() {
const [articles, setArticles] = useState<Article[]>([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchPromiseRef = useRef<Promise<ArticleListResponse> | null>(null);
記事リスト、現在のページ番号、次のページの有無、ローディング状態、エラー状態を管理しています。fetchPromiseRef は、use フックに渡す Promise を保持するための ref です。
データ取得関数を実装します。
typescript// hooks/useInfiniteScroll.ts
const fetchArticles = useCallback(() => {
if (isLoading || !hasMore) return null;
setIsLoading(true);
setError(null);
const promise = fetch(
`/api/articles?page=${page}&limit=10`
)
.then(async (res) => {
if (!res.ok) {
throw new Error('データの取得に失敗しました');
}
return res.json() as Promise<ArticleListResponse>;
})
.then((data) => {
setArticles((prev) => [...prev, ...data.articles]);
setHasMore(data.hasMore);
setPage(data.nextPage);
setIsLoading(false);
return data;
})
.catch((err) => {
setError(err.message);
setIsLoading(false);
throw err;
});
fetchPromiseRef.current = promise;
return promise;
}, [page, hasMore, isLoading]);
useCallback でメモ化することで、不要な再生成を防いでいます。ローディング中または次のページがない場合は null を返して重複リクエストを防止しています。
Intersection Observer を設定するコールバック関数を実装します。
typescript// hooks/useInfiniteScroll.ts
const observerRef = useCallback(
(node: HTMLDivElement | null) => {
if (isLoading) return;
if (!node) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore) {
fetchArticles();
}
},
{
threshold: 0.1, // 要素の10%が表示されたらトリガー
rootMargin: '100px', // 100px手前でトリガー
}
);
observer.observe(node);
return () => {
observer.disconnect();
};
},
[fetchArticles, hasMore, isLoading]
);
threshold と rootMargin を設定することで、ユーザーが画面下部に到達する前にデータを取得し始めることができます。これにより、よりスムーズな UX を実現できますね。
カスタムフックの戻り値を定義します。
typescript// hooks/useInfiniteScroll.ts
return {
articles,
hasMore,
isLoading,
error,
observerRef,
fetchPromiseRef,
};
}
必要な状態と関数をオブジェクトとして返すことで、コンポーネント側で使いやすくしています。
ステップ 5:Article コンポーネントの作成
個別の記事を表示するコンポーネントを作成します。
typescript// components/ArticleCard.tsx
'use client';
import { Article } from '@/types/article';
interface ArticleCardProps {
article: Article;
}
記事データを props として受け取る型を定義しています。
コンポーネントの本体を実装します。
typescript// components/ArticleCard.tsx
export function ArticleCard({ article }: ArticleCardProps) {
return (
<article className='border border-gray-300 rounded-lg p-6 mb-4 bg-white shadow-sm hover:shadow-md transition-shadow'>
<h2 className='text-2xl font-bold mb-2 text-gray-800'>
{article.title}
</h2>
<div className='flex items-center gap-4 mb-3 text-sm text-gray-600'>
<span className='font-medium'>
{article.author}
</span>
<span>
{new Date(article.createdAt).toLocaleDateString(
'ja-JP'
)}
</span>
</div>
<p className='text-gray-700 leading-relaxed'>
{article.content}
</p>
</article>
);
}
シンプルなカードデザインで記事を表示しています。hover:shadow-md で、マウスオーバー時に影が濃くなるアニメーションを追加しました。
ステップ 6:ローディングコンポーネントの作成
データ取得中に表示するローディングコンポーネントを作成します。
typescript// components/LoadingSpinner.tsx
'use client';
export function LoadingSpinner() {
return (
<div className='flex justify-center items-center py-8'>
<div className='animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600'></div>
</div>
);
}
Tailwind CSS のアニメーションユーティリティを使って、回転するスピナーを実装しています。
ステップ 7:メインコンポーネントの実装
すべてを統合したメインコンポーネントを作成します。
typescript// app/page.tsx
'use client';
import { use, Suspense } from 'react';
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll';
import { ArticleCard } from '@/components/ArticleCard';
import { LoadingSpinner } from '@/components/LoadingSpinner';
必要なコンポーネントとフックをインポートします。
メインコンポーネントの本体を実装します。
typescript// app/page.tsx
export default function HomePage() {
const {
articles,
hasMore,
isLoading,
error,
observerRef,
fetchPromiseRef,
} = useInfiniteScroll();
先ほど作成したカスタムフックを呼び出し、必要な値を取得しています。
記事リストと監視要素をレンダリングします。
typescript// app/page.tsx
return (
<main className="max-w-4xl mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-8 text-center text-gray-900">
インフィニットスクロール デモ
</h1>
<div className="space-y-4">
{articles.map((article) => (
<ArticleCard key={article.id} article={article} />
))}
</div>
map を使って記事リストを展開し、各記事を ArticleCard コンポーネントでレンダリングしています。
ローディング状態とエラー状態の表示を実装します。
typescript// app/page.tsx
{isLoading && <LoadingSpinner />}
{error && (
<div className="text-center py-8">
<p className="text-red-600 font-medium">
エラー: {error}
</p>
</div>
)}
{hasMore && !isLoading && !error && (
<div ref={observerRef} className="h-20 flex items-center justify-center">
<p className="text-gray-500">スクロールして続きを読み込む...</p>
</div>
)}
{!hasMore && articles.length > 0 && (
<div className="text-center py-8">
<p className="text-gray-600 font-medium">
すべての記事を表示しました
</p>
</div>
)}
</main>
);
}
observerRef を監視要素に設定することで、スクロール検知を実現しています。次のページがない場合は「すべての記事を表示しました」というメッセージを表示します。
ステップ 8:初回データ取得の実装
ページロード時に最初のデータを取得する処理を追加します。
typescript// hooks/useInfiniteScroll.ts
import {
use,
useState,
useCallback,
useRef,
useEffect,
} from 'react';
useEffect をインポートに追加します。
カスタムフック内に初回取得処理を追加します。
typescript// hooks/useInfiniteScroll.ts
export function useInfiniteScroll() {
// ... 既存のステート定義
// 初回データ取得
useEffect(() => {
if (articles.length === 0 && !isLoading) {
fetchArticles();
}
}, []); // 初回のみ実行
// ... 残りの実装
}
コンポーネントのマウント時に、記事が空でローディング中でない場合のみ、データを取得します。
エラーハンドリングの強化
より堅牢なエラーハンドリングを実装しましょう。
typescript// app/api/articles/route.ts
export async function GET(request: NextRequest) {
try {
const searchParams = request.nextUrl.searchParams;
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '10');
// バリデーション
if (page < 1 || limit < 1 || limit > 100) {
return NextResponse.json(
{ error: '不正なパラメータです' },
{ status: 400 }
);
}
ページ番号と取得件数のバリデーションを追加しました。上限を 100 件に制限することで、過度なデータ取得を防いでいます。
パフォーマンス最適化
大量のデータを扱う場合、パフォーマンス最適化が重要になります。
typescript// components/ArticleCard.tsx
import { memo } from 'react';
export const ArticleCard = memo(function ArticleCard({
article,
}: ArticleCardProps) {
// ... コンポーネントの実装
});
memo で記事カードコンポーネントをメモ化することで、不要な再レンダリングを防ぎます。props が変わらない限り、コンポーネントは再利用されるのです。
デバウンス処理の追加
スクロールイベントが頻繁に発生する場合に備えて、デバウンス処理を追加します。
typescript// hooks/useInfiniteScroll.ts
const observerRef = useCallback((node: HTMLDivElement | null) => {
if (isLoading) return;
if (!node) return;
let timeoutId: NodeJS.Timeout;
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMore) {
// 300ms のデバウンス
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
fetchArticles();
}, 300);
}
}, {
threshold: 0.1,
rootMargin: '100px',
});
300ms のデバウンスを追加することで、短時間に複数回のリクエストが発生することを防ぎます。
図の意図:最終的な実装の全体フローを示します。
mermaidsequenceDiagram
participant User as ユーザー
participant Page as ページコンポーネント
participant Hook as useInfiniteScroll
participant Observer as Intersection<br/>Observer
participant API as Route Handler
participant DB as データソース
User->>Page: ページアクセス
Page->>Hook: 初期化
Hook->>API: 初回データ取得
API->>DB: データクエリ
DB-->>API: データ返却
API-->>Hook: JSON レスポンス
Hook-->>Page: 記事リスト更新
Page-->>User: 記事表示
User->>Page: スクロール
Observer->>Hook: 要素が表示された
Hook->>API: 次ページ取得
API->>DB: データクエリ
DB-->>API: データ返却
API-->>Hook: JSON レスポンス
Hook-->>Page: 記事リスト追加
Page-->>User: 追加記事表示
図で理解できる要点:
- ページアクセス時に初回データを自動取得します
- ユーザーがスクロールすると Intersection Observer が検知します
- データ取得は Route Handler を経由してサーバーサイドで行われます
- 新しいデータは既存のリストに追加されていきます
まとめ
本記事では、Next.js の Route Handlers と React 19 の use フックを組み合わせて、インフィニットスクロールを実装する方法を解説しました。
実装のポイントをまとめると以下のようになります。
| # | ポイント | 詳細 |
|---|---|---|
| 1 | Route Handlers の活用 | サーバーサイドでデータを提供し、型安全な API を構築 |
| 2 | use フックの採用 | Promise を直接扱うことでコードをシンプル化 |
| 3 | Intersection Observer | スクロール位置を効率的に監視し、UX を向上 |
| 4 | カスタムフック化 | ロジックを再利用可能にし、保守性を向上 |
| 5 | エラーハンドリング | 適切なバリデーションとエラー表示で堅牢性を確保 |
| 6 | パフォーマンス最適化 | メモ化とデバウンスで快適な操作感を実現 |
この実装パターンは、ブログ記事一覧、商品リスト、SNS のタイムラインなど、様々な場面で応用できます。特に、use フックを活用することで、従来の useEffect と useState の組み合わせよりも、はるかに読みやすく保守しやすいコードになりました。
また、Route Handlers を使うことで、API の型安全性が保たれ、フロントエンドとバックエンドの連携がスムーズになります。Next.js の App Router の恩恵を最大限に活用できる実装パターンと言えるでしょう。
ぜひ、この実装を参考に、あなたのプロジェクトでもインフィニットスクロールを導入してみてください。ユーザー体験の向上につながること間違いなしです。
関連リンク
articleNext.js でインフィニットスクロールを実装:Route Handlers +`use` で滑らかデータ読込
articleRedis 使い方:Next.js で Cache-Tag と再検証を実装(Edge/Node 両対応)
articleNext.js の RSC 境界設計:Client Components を最小化する責務分離戦略
articleNext.js ルーティング早見表:セグメント・グループ・オプションの一枚まとめ
articleNext.js × pnpm/Turborepo 初期構築:ワークスペース・共有パッケージ・CI 最適化
articleNext.js Route Handlers vs API Routes:設計意図・性能・制約のリアル比較
articleShell Script とは?初心者が最短で理解する基本構文・実行モデル・活用領域
articleNode.js 本番メモリ運用:ヒープ/外部メモリ/リーク検知の継続監視
articleReact とは? 2025 年版の特徴・強み・実務活用を一気に理解する完全解説
articleNext.js でインフィニットスクロールを実装:Route Handlers +`use` で滑らかデータ読込
articleDocker を用いた統一ローカル環境:新人オンボーディングを 1 日 → 1 時間へ
articleMermaid でデータ基盤のラインジを図解:ETL/DAG/品質チェックの全体像
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来