サイトアイコンmaita tomoya dev io

#17 Next.jsでページネーション実装とWebアプリケーションの状態管理を完全理解

Next.jsでページネーション実装とWebアプリケーションの状態管理を完全理解

はじめに

技術ブログの記事が16本を超えて、一覧ページが長くなりすぎる問題に直面しました。今回はNext.js App Routerでページネーション機能を実装しながら、Webアプリケーションの状態管理について深く理解していきます。

「なぜHTMLとCSS/JavaScriptで状態を管理できるのか?」「内部ストレージに保持しているの?」といった疑問も解決していきます。

現在の問題点

// 現在の実装:全記事を一度に表示
export default function BlogIndex() {
  const posts = getSortedPostsData() // 全16記事を取得
  
  return (
    <ul>
      {posts.map((post) => ( // 全記事を表示
        <li key={post.slug}>{post.title}</li>
      ))}
    </ul>
  )
}

問題点の整理

  1. パフォーマンス問題

    • 記事数が増えるとHTMLサイズが肥大化
    • ブラウザのレンダリング負荷が増大
    • 初期表示時間が遅くなる
  2. ユーザビリティ問題

    • スクロールが長すぎて目的の記事を見つけにくい
    • 古い記事にアクセスしづらい

Webアプリケーションの状態管理とは

状態(State)とは何か

状態とは、アプリケーションが「今どのような状態にあるか」を表す情報です。

例えば:

  • 現在表示しているページ番号
  • ユーザーがログインしているか
  • フォームに入力された値
  • 選択されたタブ

状態を管理する3つの場所

1. クライアントサイド(ブラウザ)の状態管理

// 1. JavaScriptのメモリ(変数)
let currentPage = 1;

// 2. LocalStorage(永続的)
localStorage.setItem('currentPage', '1');
// ブラウザを閉じても保持される

// 3. SessionStorage(一時的)
sessionStorage.setItem('currentPage', '1');
// タブを閉じると消える

// 4. Cookie
document.cookie = 'currentPage=1; path=/';
// サーバーにも送信される

特徴

  • メモリ:ページをリロードすると消える
  • LocalStorage:最大5MB程度、永続的に保存
  • SessionStorage:タブごとに独立、タブを閉じると消える
  • Cookie:最大4KB、サーバーとの通信時に自動送信

2. URL(アドレスバー)による状態管理

https://example.com/blog/page/2/
                          ↑
                    この部分が状態を表す

なぜURLで状態管理ができるのか

  1. ブラウザの仕組み

    • URLはブラウザが管理する「現在地」情報
    • 履歴API(History API)でURLを操作可能
    • ブックマーク可能、共有可能
  2. HTTPプロトコルの特性

    • URLはリソースの識別子
    • /blog/page/2/は「2ページ目」というリソースを指す
// ブラウザの履歴APIを使った例
window.history.pushState({page: 2}, '', '/blog/page/2/');
// URLが変わるが、ページはリロードされない

3. サーバーサイドの状態管理

// データベース
const user = await db.user.findUnique({
  where: { id: userId }
});

// セッション(サーバー側)
req.session.currentPage = 2;

// キャッシュ
redis.set('user:123:page', '2');

Next.jsの静的サイト生成(SSG)での状態管理

SSGの仕組み

Next.jsのoutput: 'export'設定では、ビルド時にすべてのページを静的HTMLとして生成します。

// next.config.ts
const nextConfig = {
  output: 'export', // 静的サイト生成モード
}

ビルド時の処理フロー

1. ビルド開始
   ↓
2. generateStaticParams()を実行
   - 必要なページ数を計算
   - [1, 2, 3...]のパスを生成
   ↓
3. 各ページのHTMLを生成
   - /blog/page/1/index.html
   - /blog/page/2/index.html
   ↓
4. 静的ファイルとして出力

なぜ内部ストレージは不要なのか

静的サイトでは、各ページが独立したHTMLファイルとして存在します。

out/
├── blog/
│   ├── page/
│   │   ├── 1/
│   │   │   └── index.html  ← 1ページ目の完全なHTML
│   │   └── 2/
│   │       └── index.html  ← 2ページ目の完全なHTML

状態はURLそのものが表現

  • /blog/page/1/ → 1ページ目を表示
  • /blog/page/2/ → 2ページ目を表示

内部ストレージは不要で、URLとファイルシステムの対応だけで状態管理が完結します。

実装手順

1. ページネーション用の関数を追加

// src/lib/posts.ts
export const POSTS_PER_PAGE = 10;

export function getPostsWithPagination(page: number) {
  const allPosts = getSortedPostsData();
  const totalPosts = allPosts.length;
  const totalPages = Math.ceil(totalPosts / POSTS_PER_PAGE);
  
  // ページ番号の検証
  if (page < 1 || page > totalPages) {
    return {
      posts: [],
      currentPage: page,
      totalPages,
      hasNextPage: false,
      hasPrevPage: false,
    };
  }
  
  // 該当ページのデータを切り出し
  const startIndex = (page - 1) * POSTS_PER_PAGE;
  const endIndex = startIndex + POSTS_PER_PAGE;
  const posts = allPosts.slice(startIndex, endIndex);
  
  return {
    posts,
    currentPage: page,
    totalPages,
    hasNextPage: page < totalPages,
    hasPrevPage: page > 1,
  };
}

2. 動的ルートの実装

// src/app/blog/page/[pageNum]/page.tsx

// ビルド時に全ページ分のパスを生成
export async function generateStaticParams() {
  const totalPages = getTotalPages();
  const pages = [];
  
  for (let i = 1; i <= totalPages; i++) {
    pages.push({ pageNum: i.toString() });
  }
  
  return pages;
  // これにより以下のHTMLが生成される:
  // /blog/page/1/index.html
  // /blog/page/2/index.html
}

export default async function BlogPage({ params }: PageProps) {
  const pageNumber = parseInt(params.pageNum, 10);
  const { posts, currentPage, totalPages } = getPostsWithPagination(pageNumber);
  
  return (
    <div>
      <h1>記事一覧</h1>
      <p>ページ {currentPage} / {totalPages}</p>
      
      <ul>
        {posts.map((post) => (
          <li key={post.slug}>{post.title}</li>
        ))}
      </ul>
      
      <Pagination 
        currentPage={currentPage} 
        totalPages={totalPages} 
      />
    </div>
  );
}

3. Paginationコンポーネント

// src/components/Pagination.tsx
export default function Pagination({ currentPage, totalPages }: Props) {
  // ページ番号の配列を生成(例:[1, 2, '...', 5, 6, 7, '...', 10])
  const getPageNumbers = () => {
    const delta = 2; // 現在のページの前後に表示するページ数
    const range: number[] = [];
    const rangeWithDots: (number | string)[] = [];
    
    for (let i = 1; i <= totalPages; i++) {
      if (i === 1 || i === totalPages || 
          (i >= currentPage - delta && i <= currentPage + delta)) {
        range.push(i);
      }
    }
    
    // 省略記号(...)を追加
    let l: number | undefined;
    range.forEach((i) => {
      if (l) {
        if (i - l === 2) {
          rangeWithDots.push(l + 1);
        } else if (i - l !== 1) {
          rangeWithDots.push('...');
        }
      }
      rangeWithDots.push(i);
      l = i;
    });
    
    return rangeWithDots;
  };
  
  return (
    <nav aria-label="ページネーション">
      {/* 前へボタン */}
      {currentPage > 1 ? (
        <Link href={`/blog/page/${currentPage - 1}/`}>
          ← 前へ
        </Link>
      ) : (
        <span className="disabled">← 前へ</span>
      )}
      
      {/* ページ番号 */}
      {getPageNumbers().map((pageNum, index) => {
        if (pageNum === '...') {
          return <span key={`dots-${index}`}>…</span>;
        }
        
        const page = pageNum as number;
        if (page === currentPage) {
          return <span key={page} aria-current="page">{page}</span>;
        }
        
        return (
          <Link key={page} href={`/blog/page/${page}/`}>
            {page}
          </Link>
        );
      })}
      
      {/* 次へボタン */}
      {currentPage < totalPages ? (
        <Link href={`/blog/page/${currentPage + 1}/`}>
          次へ →
        </Link>
      ) : (
        <span className="disabled">次へ →</span>
      )}
    </nav>
  );
}

状態管理パターンの比較

1. URLベース(今回採用)

/blog/page/2/

メリット

  • ブックマーク可能
  • 共有可能(URLを送れば同じページが見れる)
  • SEOに有利
  • ブラウザの戻る/進むボタンが機能

デメリット

  • URLが変わるとページ全体がリロード(SPAでない場合)

2. クエリパラメータベース

/blog?page=2

メリット

  • 実装が簡単
  • 動的に変更しやすい

デメリット

  • URLが美しくない
  • 静的生成には向かない

3. JavaScriptの状態管理

const [currentPage, setCurrentPage] = useState(1);

メリット

  • 高速(ページリロードなし)
  • 複雑な状態も管理可能

デメリット

  • リロードで状態が消える
  • 共有不可
  • SEOに不利

パフォーマンスの改善効果

Before(全記事表示)

- HTML サイズ: 約50KB(16記事)
- DOM ノード数: 約500個
- 初期レンダリング: 約200ms

After(10記事ずつ)

- HTML サイズ: 約30KB(10記事)
- DOM ノード数: 約300個
- 初期レンダリング: 約120ms
- 改善率: 約40%

トラブルシューティング

遭遇したエラーと解決方法

1. 動的パスの競合エラー

Error: You cannot use different slug names for the same dynamic path

原因:同じ階層に[slug][page]があった

解決:ディレクトリ構造を変更

blog/
├── [slug]/     # 記事詳細
└── page/       # ページネーション用
    └── [pageNum]/

2. TypeScriptの型エラー

// エラー:rangeWithDots has implicit type 'any[]'
const rangeWithDots = [];

// 解決:明示的な型定義
const rangeWithDots: (number | string)[] = [];

まとめ

学んだこと

  1. 状態管理の本質

    • 状態は「情報をどこに保存するか」の問題
    • URL、メモリ、ストレージ、サーバーなど選択肢は多様
  2. 静的サイトでの状態管理

    • URLとファイルシステムの対応で実現
    • 内部ストレージは不要
    • ビルド時にすべてのパターンを生成
  3. Next.jsの強み

    • generateStaticParamsで静的ページを量産
    • URLベースの美しい状態管理
    • SEOとパフォーマンスの両立

今後の拡張案

  1. 検索機能の追加

    • タグによるフィルタリング
    • 全文検索の実装
  2. 無限スクロール

    • Intersection Observer APIの活用
    • 段階的な記事読み込み
  3. ユーザー設定の保存

    • 1ページあたりの表示件数をLocalStorageに保存
    • ダークモード設定の永続化

参考資料