T-CREATOR

Next.js でインフィニットスクロールを実装:Route Handlers +`use` で滑らかデータ読込

Next.js でインフィニットスクロールを実装:Route Handlers +`use` で滑らかデータ読込

現代の Web アプリケーションにおいて、インフィニットスクロールは非常に人気の高い UX パターンとなっています。ユーザーがページの最下部に到達すると、自動的に次のコンテンツが読み込まれる仕組みは、Twitter や Instagram など多くのサービスで採用されていますね。

本記事では、Next.js の App Router における Route Handlers と React の use フックを組み合わせて、パフォーマンスに優れたインフィニットスクロールを実装する方法を解説します。段階的にコードを紐解きながら、実践的な実装パターンをお伝えしていきますので、ぜひ最後までお付き合いください。

背景

インフィニットスクロールの需要

従来のページネーション方式では、ユーザーは「次へ」ボタンをクリックして新しいページに移動する必要がありました。しかし、モバイルデバイスの普及により、スクロールだけで次々とコンテンツを閲覧できるインフィニットスクロールが主流になってきています。

Next.js における実装の選択肢

Next.js の App Router では、データ取得の方法として以下の選択肢があります。

#方式特徴適用場面
1Server Componentsサーバーサイドでデータ取得初期ロード時の静的データ
2Client Components + fetchクライアントサイドでデータ取得動的なユーザー操作に応じたデータ
3Route HandlersAPI ルートとしてデータ提供柔軟なデータ取得、外部からのアクセス

インフィニットスクロールの場合、スクロール位置に応じて動的にデータを取得する必要があるため、Route Handlers と Client Components を組み合わせるのが最適解となります。

React 19 の use フックの登場

React 19 で導入された use フックは、Promise を直接コンポーネント内で解決できる画期的な機能です。従来の useEffectuseState の組み合わせに比べて、よりシンプルで読みやすいコードが書けるようになりました。

図の意図:従来の実装方法と新しい 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 がサーバーサイドでデータを提供します

実装の基本方針

  1. Route Handler でデータ取得 API を作成:ページネーション対応のエンドポイントを実装します
  2. Intersection Observer でスクロール監視:画面下部の要素が表示されたら次のデータを取得します
  3. 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;

クエリパラメータから pagelimit を取得し、データベースクエリ用のオフセットを計算しています。デフォルト値として、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]
);

thresholdrootMargin を設定することで、ユーザーが画面下部に到達する前にデータを取得し始めることができます。これにより、よりスムーズな 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 フックを組み合わせて、インフィニットスクロールを実装する方法を解説しました。

実装のポイントをまとめると以下のようになります。

#ポイント詳細
1Route Handlers の活用サーバーサイドでデータを提供し、型安全な API を構築
2use フックの採用Promise を直接扱うことでコードをシンプル化
3Intersection Observerスクロール位置を効率的に監視し、UX を向上
4カスタムフック化ロジックを再利用可能にし、保守性を向上
5エラーハンドリング適切なバリデーションとエラー表示で堅牢性を確保
6パフォーマンス最適化メモ化とデバウンスで快適な操作感を実現

この実装パターンは、ブログ記事一覧、商品リスト、SNS のタイムラインなど、様々な場面で応用できます。特に、use フックを活用することで、従来の useEffectuseState の組み合わせよりも、はるかに読みやすく保守しやすいコードになりました。

また、Route Handlers を使うことで、API の型安全性が保たれ、フロントエンドとバックエンドの連携がスムーズになります。Next.js の App Router の恩恵を最大限に活用できる実装パターンと言えるでしょう。

ぜひ、この実装を参考に、あなたのプロジェクトでもインフィニットスクロールを導入してみてください。ユーザー体験の向上につながること間違いなしです。

関連リンク