Skip to content

Instantly share code, notes, and snippets.

@kobitoDevelopment
Last active September 20, 2025 11:10
Show Gist options
  • Select an option

  • Save kobitoDevelopment/52d52a67ba364f9aa8ebbd9b27c0a963 to your computer and use it in GitHub Desktop.

Select an option

Save kobitoDevelopment/52d52a67ba364f9aa8ebbd9b27c0a963 to your computer and use it in GitHub Desktop.

1. 必要な情報

  • 総データ数: 全部で何件のデータがあるか(例:100 件)
  • 1 ページあたりの表示件数: 1 ページに何件表示するか(例:3 件)
  • 現在のページ番号: 今何ページ目を見ているか(例:5 ページ目)

2. 計算で求められる情報

/* 総ページ数 = 総データ数 ÷ 1ページあたりの件数(切り上げ) */
const totalPages = Math.ceil(totalItems / itemsPerPage);
// 例: Math.ceil(100 / 3) = Math.ceil(33.333...) = 34ページ

/* 表示開始位置 = (現在のページ - 1) × 1ページあたりの件数 */
const startIndex = (currentPage - 1) * itemsPerPage;
// 例: 5ページ目なら (5 - 1) × 3 = 12(13番目のデータから表示)

/* 表示終了位置 = 開始位置 + 1ページあたりの件数(ただし総データ数を超えない) */
const endIndex = Math.min(startIndex + itemsPerPage, totalItems);
// 例: Math.min(12 + 3, 100) = 15(15番目のデータまで表示)

3. ナビゲーション情報

  • 前のページがあるか: currentPage > 1
  • 次のページがあるか: currentPage < totalPages
  • 前のページ番号: あれば currentPage - 1、なければ null
  • 次のページ番号: あれば currentPage + 1、なければ null

※ユーザーは1ページ目から順番に「進む」「戻る」を行うわけではないという前提が必要。 3ページ目や4ページ目に直接アクセスされた場合を考慮し、オンメモリでの履歴保存は避ける。

1. ページ番号の検証

ユーザーが無効なページ番号を指定した場合の対策:

// 1以上、総ページ数以下に収める
const validCurrentPage = Math.max(1, Math.min(currentPage, totalPages));

2. データの切り出し

配列から必要な部分だけを取り出す:

const currentPageData = allData.slice(startIndex, endIndex);
// 例: articles.slice(12, 15) は13番目から15番目のデータを取得

3. 省略記号付きページ番号表示

多くのページがある場合、すべてのページ番号を表示すると見づらいため、省略記号(...)を使います:

1 ... 5 6 7 ... 34

実装の考え方:

  1. 最初のページ(1)は常に表示
  2. 最後のページ(34)は常に表示
  3. 現在のページとその前後 2 ページは表示
  4. それ以外は省略記号で表す

前後の記事取得

現在選択している記事の前後の記事を取得する機能:

// 現在の記事のインデックスを探す
const currentIndex = articles.findIndex((article) => article.id === currentArticleId);

// 前の記事: インデックスが0より大きければ存在
const prevArticle = currentIndex > 0 ? articles[currentIndex - 1] : null;

// 次の記事: インデックスが最後でなければ存在
const nextArticle = currentIndex < articles.length - 1 ? articles[currentIndex + 1] : null;

URL パラメータとの同期

URL パラメータの形式

  • ?page=N: ページ番号を指定(例: ?page=5
  • ?article=ID: 選択記事 ID を指定(例: ?article=10
  • 組み合わせ例: ?page=3&article=15
// URLパラメータの読み取り
const searchParams = useSearchParams();
const initialPage = parseInt(searchParams.get("page") || "1", 10);

// URLパラメータの更新
const updateUrl = (newPage, newArticleId = null) => {
  const params = new URLSearchParams();
  if (newPage > 1) params.set("page", newPage.toString());
  if (newArticleId) params.set("article", newArticleId.toString());

  const queryString = params.toString();
  const newUrl = queryString ? `?${queryString}` : "/benkyo/paging";
  router.push(newUrl);
};
[
{ "id": 1, "title": "記事1", "content": "記事1の内容です。", "date": "2025-01-01" },
{ "id": 2, "title": "記事2", "content": "記事2の内容です。", "date": "2025-01-02" },
{ "id": 3, "title": "記事3", "content": "記事3の内容です。", "date": "2025-01-03" },
{ "id": 4, "title": "記事4", "content": "記事4の内容です。", "date": "2025-01-04" },
{ "id": 5, "title": "記事5", "content": "記事5の内容です。", "date": "2025-01-05" },
{ "id": 6, "title": "記事6", "content": "記事6の内容です。", "date": "2025-01-06" },
{ "id": 7, "title": "記事7", "content": "記事7の内容です。", "date": "2025-01-07" },
{ "id": 8, "title": "記事8", "content": "記事8の内容です。", "date": "2025-01-08" },
{ "id": 9, "title": "記事9", "content": "記事9の内容です。", "date": "2025-01-09" },
{ "id": 10, "title": "記事10", "content": "記事10の内容です。", "date": "2025-01-10" },
{ "id": 11, "title": "記事11", "content": "記事11の内容です。", "date": "2025-01-11" },
{ "id": 12, "title": "記事12", "content": "記事12の内容です。", "date": "2025-01-12" },
{ "id": 13, "title": "記事13", "content": "記事13の内容です。", "date": "2025-01-13" },
{ "id": 14, "title": "記事14", "content": "記事14の内容です。", "date": "2025-01-14" },
{ "id": 15, "title": "記事15", "content": "記事15の内容です。", "date": "2025-01-15" },
{ "id": 16, "title": "記事16", "content": "記事16の内容です。", "date": "2025-01-16" },
{ "id": 17, "title": "記事17", "content": "記事17の内容です。", "date": "2025-01-17" },
{ "id": 18, "title": "記事18", "content": "記事18の内容です。", "date": "2025-01-18" },
{ "id": 19, "title": "記事19", "content": "記事19の内容です。", "date": "2025-01-19" },
{ "id": 20, "title": "記事20", "content": "記事20の内容です。", "date": "2025-01-20" },
{ "id": 21, "title": "記事21", "content": "記事21の内容です。", "date": "2025-01-21" },
{ "id": 22, "title": "記事22", "content": "記事22の内容です。", "date": "2025-01-22" },
{ "id": 23, "title": "記事23", "content": "記事23の内容です。", "date": "2025-01-23" },
{ "id": 24, "title": "記事24", "content": "記事24の内容です。", "date": "2025-01-24" },
{ "id": 25, "title": "記事25", "content": "記事25の内容です。", "date": "2025-01-25" },
{ "id": 26, "title": "記事26", "content": "記事26の内容です。", "date": "2025-01-26" },
{ "id": 27, "title": "記事27", "content": "記事27の内容です。", "date": "2025-01-27" },
{ "id": 28, "title": "記事28", "content": "記事28の内容です。", "date": "2025-01-28" },
{ "id": 29, "title": "記事29", "content": "記事29の内容です。", "date": "2025-01-29" },
{ "id": 30, "title": "記事30", "content": "記事30の内容です。", "date": "2025-01-30" }
]
"use client";
import { usePagination } from "./service";
export default function PagingPage() {
// usePaginationカスタムフックからページング関連の状態と関数を取得
// 引数の3は1ページあたりの表示件数を指定
const {
selectedArticleId, // 選択中の記事ID
paginationInfo, // ページング情報(現在ページ、総ページ数など)
currentPageArticles, // 現在のページに表示する記事配列
adjacentArticles, // 選択記事の前後の記事
pageNumbers, // ページ番号リスト(省略記号含む)
handlePageChange, // ページ変更ハンドラー
handleArticleSelect, // 記事選択ハンドラー
} = usePagination(3);
return (
<div style={{ padding: "20px", maxWidth: "800px", margin: "0 auto" }}>
{/* ページング情報の表示エリア */}
<div style={{ marginBottom: "20px", padding: "10px", backgroundColor: "#f5f5f5" }}>
<ul>
<li>現在のページ番号: {paginationInfo.currentPage}</li>
<li>前へのページ番号: {paginationInfo.prevPage ?? "なし"}</li>
<li>次へのページ番号: {paginationInfo.nextPage ?? "なし"}</li>
<li>総ページ数: {paginationInfo.totalPages}</li>
<li>総記事数: {paginationInfo.totalItems}</li>
<li>1ページあたりの表示件数: {paginationInfo.itemsPerPage}</li>
<li>
{/* startIndexは0から始まるので+1して1から始まる番号に変換 */}
現在の表示範囲: {paginationInfo.startIndex + 1} - {paginationInfo.endIndex}
</li>
</ul>
</div>
{/* 選択記事の前後記事表示エリア(記事が選択されている場合のみ表示) */}
{selectedArticleId && adjacentArticles && (
<div style={{ marginBottom: "20px", padding: "10px", backgroundColor: "#e8f4f8" }}>
<h3>選択中の記事の前後</h3>
<ul>
<li>前の記事: {adjacentArticles.prev ? `${adjacentArticles.prev.title} (ID: ${adjacentArticles.prev.id})` : "なし"}</li>
<li>次の記事: {adjacentArticles.next ? `${adjacentArticles.next.title} (ID: ${adjacentArticles.next.id})` : "なし"}</li>
</ul>
</div>
)}
{/* 記事一覧表示エリア */}
<div style={{ marginBottom: "20px" }}>
<h2>記事一覧</h2>
{/* 現在のページの記事をすべて表示 */}
{currentPageArticles.map((article) => (
<div
key={article.id}
style={{
padding: "10px",
border: "1px solid #ddd",
marginBottom: "10px",
cursor: "pointer",
// 選択中の記事は背景色を変更
backgroundColor: selectedArticleId === article.id ? "#ffffcc" : "white",
}}
// 記事をクリックした時の処理
onClick={() => handleArticleSelect(article.id)}
>
<h3>{article.title}</h3>
<p>{article.content}</p>
<small>日付: {article.date}</small>
</div>
))}
</div>
{/* ページネーションナビゲーションエリア */}
<div style={{ display: "flex", gap: "10px", justifyContent: "center" }}>
{/* 前へボタン */}
<button
onClick={() => handlePageChange(paginationInfo.currentPage - 1)}
disabled={!paginationInfo.hasPrev} // 前のページがない場合は無効化
style={{
padding: "10px 20px",
cursor: paginationInfo.hasPrev ? "pointer" : "not-allowed",
opacity: paginationInfo.hasPrev ? 1 : 0.5, // 無効時は薄く表示
}}
>
前へ
</button>
{/* ページ番号ボタンまたは省略記号の表示 */}
{pageNumbers.map((page, index) => {
// 省略記号の場合はクリックできないspanで表示
if (page === "...") {
return (
<span key={`ellipsis-${index}`} style={{ padding: "10px 5px" }}>
...
</span>
);
}
// ページ番号の場合はクリック可能なボタンで表示
const pageNumber = page as number;
return (
<button
key={pageNumber}
onClick={() => handlePageChange(pageNumber)}
style={{
padding: "10px 15px",
cursor: "pointer",
// 現在のページは青色、それ以外は白色
backgroundColor: pageNumber === paginationInfo.currentPage ? "#007bff" : "white",
color: pageNumber === paginationInfo.currentPage ? "white" : "black",
border: "1px solid #ddd",
}}
>
{pageNumber}
</button>
);
})}
{/* 次へボタン */}
<button
onClick={() => handlePageChange(paginationInfo.currentPage + 1)}
disabled={!paginationInfo.hasNext} // 次のページがない場合は無効化
style={{
padding: "10px 20px",
cursor: paginationInfo.hasNext ? "pointer" : "not-allowed",
opacity: paginationInfo.hasNext ? 1 : 0.5, // 無効時は薄く表示
}}
>
次へ
</button>
</div>
</div>
);
}
import { useState, useMemo, useEffect } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import articlesData from './data.json'
import type { Article, PaginationInfo, AdjacentArticles } from './type'
/**
* ページング情報を計算する関数
*
* @param currentPage - 現在のページ番号(1から始まる)
* @param totalItems - 総データ件数
* @param itemsPerPage - 1ページあたりの表示件数
* @returns ページング情報オブジェクト
*/
export const calculatePaginationInfo = (
currentPage: number,
totalItems: number,
itemsPerPage: number
): PaginationInfo => {
// 総ページ数を計算(小数点以下は切り上げ)
// 例: 100件のデータを3件ずつ表示 → Math.ceil(100/3) = 34ページ
const totalPages = Math.ceil(totalItems / itemsPerPage)
// 現在のページ番号を有効な範囲内に収める(1以上、総ページ数以下)
// ユーザーが無効なページ番号を指定した場合の安全対策
const validCurrentPage = Math.max(1, Math.min(currentPage, totalPages))
// 表示開始位置のインデックスを計算(0から始まる)
// 例: 5ページ目なら (5-1) × 3 = 12(配列の12番目から開始)
const startIndex = (validCurrentPage - 1) * itemsPerPage
// 表示終了位置のインデックスを計算(総データ数を超えないように調整)
// 例: startIndex=12, itemsPerPage=3なら 12+3=15だが、総データ数100を超えない
const endIndex = Math.min(startIndex + itemsPerPage, totalItems)
// 次のページがあるかどうか判定
const hasNext = validCurrentPage < totalPages
// 前のページがあるかどうか判定
const hasPrev = validCurrentPage > 1
return {
currentPage: validCurrentPage,
totalPages,
totalItems,
itemsPerPage,
hasNext,
hasPrev,
// 次のページ番号(なければnull)
nextPage: hasNext ? validCurrentPage + 1 : null,
// 前のページ番号(なければnull)
prevPage: hasPrev ? validCurrentPage - 1 : null,
startIndex,
endIndex
}
}
/**
* 指定された記事の前後の記事を取得する関数
*
* @param articles - 全記事の配列
* @param currentArticleId - 現在の記事のID
* @returns 前後の記事オブジェクト
*/
export const getAdjacentArticles = (
articles: Article[],
currentArticleId: number
): AdjacentArticles => {
// 現在の記事が配列の何番目にあるかを検索
// findIndexは見つからない場合-1を返す
const currentIndex = articles.findIndex(article => article.id === currentArticleId)
return {
// 次の記事: 現在のインデックスが最後でなければ存在
next: currentIndex < articles.length - 1 ? articles[currentIndex + 1] : null,
// 前の記事: 現在のインデックスが0より大きければ存在
prev: currentIndex > 0 ? articles[currentIndex - 1] : null
}
}
/**
* 省略記号付きのページ番号リストを生成する関数
* 例: [1, "...", 5, 6, 7, "...", 20] のような形式
*
* @param currentPage - 現在のページ番号
* @param totalPages - 総ページ数
* @param delta - 現在のページの前後に表示するページ数(デフォルト: 2)
* @returns ページ番号と省略記号の配列
*/
export const getPageNumbers = (
currentPage: number,
totalPages: number,
delta: number = 2
): (number | string)[] => {
const pageNumbers: (number | string)[] = []
// 総ページ数が7以下の場合は全ページを表示
// 省略の必要がないため
if (totalPages <= 7) {
for (let i = 1; i <= totalPages; i++) {
pageNumbers.push(i)
}
} else {
// 複雑なページング表示のロジック
// 常に最初のページ(1)を表示
pageNumbers.push(1)
// 現在のページが最初の方から離れている場合、省略記号を表示
// delta + 2 = 2 + 2 = 4より大きい場合(5ページ目以降)
if (currentPage > delta + 2) {
pageNumbers.push('...')
}
// 現在のページの前後delta個分のページを表示
// ただし、2ページ目以降、最後から2ページ目まで
for (let i = Math.max(2, currentPage - delta); i <= Math.min(totalPages - 1, currentPage + delta); i++) {
pageNumbers.push(i)
}
// 現在のページが最後の方から離れている場合、省略記号を表示
if (currentPage < totalPages - delta - 1) {
pageNumbers.push('...')
}
// 常に最後のページを表示
pageNumbers.push(totalPages)
}
return pageNumbers
}
/**
* 全記事データを取得する関数
*
* @returns 記事の配列
*/
export const getArticles = (): Article[] => {
return articlesData
}
/**
* 指定された範囲の記事を切り出す関数
*
* @param articles - 全記事の配列
* @param startIndex - 開始インデックス
* @param endIndex - 終了インデックス
* @returns 指定範囲の記事配列
*/
export const getPaginatedArticles = (
articles: Article[],
startIndex: number,
endIndex: number
): Article[] => {
// Array.slice()で指定範囲の要素を取得
// startIndex以上endIndex未満の要素を返す
return articles.slice(startIndex, endIndex)
}
/**
* usePaginationカスタムフックの戻り値の型定義
*/
export type UsePaginationResult = {
currentPage: number // 現在のページ番号
selectedArticleId: number | null // 選択中の記事ID
paginationInfo: PaginationInfo // ページング情報
currentPageArticles: Article[] // 現在のページの記事配列
adjacentArticles: AdjacentArticles | null // 選択記事の前後の記事
pageNumbers: (number | string)[] // ページ番号リスト(省略記号含む)
handlePageChange: (page: number) => void // ページ変更ハンドラー
handleArticleSelect: (articleId: number) => void // 記事選択ハンドラー
}
/**
* ページング機能を提供するカスタムフック(URL同期版)
*
* このフックは以下の機能を提供します:
* - ページング状態の管理
* - URLパラメータとの同期
* - ページング情報の計算
* - 現在のページの記事取得
* - 選択記事の前後記事取得
* - ページ番号リストの生成
* - イベントハンドラーの提供
*
* URLパラメータ:
* - ?page=N: ページ番号(例: ?page=5)
* - ?article=ID: 選択記事ID(例: ?article=10)
*
* @param itemsPerPage - 1ページあたりの表示件数(デフォルト: 5)
* @returns ページング関連の状態と関数
*/
export const usePagination = (itemsPerPage: number = 5): UsePaginationResult => {
// Next.jsのルーティング機能
const router = useRouter()
const searchParams = useSearchParams()
// URLパラメータから初期値を取得
const initialPage = parseInt(searchParams.get('page') || '1', 10)
const initialArticleId = searchParams.get('article') ? parseInt(searchParams.get('article') || '0', 10) : null
// 現在のページ番号の状態管理(URLパラメータから初期化)
const [currentPage, setCurrentPage] = useState(initialPage)
// 選択中の記事IDの状態管理(URLパラメータから初期化)
const [selectedArticleId, setSelectedArticleId] = useState<number | null>(initialArticleId)
// 全記事データを取得
const articles = getArticles()
// URLパラメータが変更された時に状態を同期
useEffect(() => {
const page = parseInt(searchParams.get('page') || '1', 10)
const articleId = searchParams.get('article') ? parseInt(searchParams.get('article') || '0', 10) : null
setCurrentPage(page)
setSelectedArticleId(articleId)
}, [searchParams])
// ページング情報をメモ化して計算
// currentPage, articles.length, itemsPerPageが変更された時のみ再計算
const paginationInfo = useMemo(() => {
return calculatePaginationInfo(currentPage, articles.length, itemsPerPage)
}, [currentPage, articles.length, itemsPerPage])
// 現在のページの記事配列をメモ化して計算
// articles, startIndex, endIndexが変更された時のみ再計算
const currentPageArticles = useMemo(() => {
return getPaginatedArticles(articles, paginationInfo.startIndex, paginationInfo.endIndex)
}, [articles, paginationInfo.startIndex, paginationInfo.endIndex])
// 選択記事の前後記事をメモ化して計算
// articles, selectedArticleIdが変更された時のみ再計算
const adjacentArticles = useMemo(() => {
if (selectedArticleId) {
return getAdjacentArticles(articles, selectedArticleId)
} else {
return null
}
}, [articles, selectedArticleId])
// ページ番号リストをメモ化して計算
// currentPage, totalPagesが変更された時のみ再計算
const pageNumbers = useMemo(() => {
return getPageNumbers(paginationInfo.currentPage, paginationInfo.totalPages)
}, [paginationInfo.currentPage, paginationInfo.totalPages])
/**
* URLパラメータを更新する関数
*
* @param newPage - 新しいページ番号
* @param newArticleId - 新しい記事ID(nullの場合はパラメータを削除)
*/
const updateUrl = (newPage: number, newArticleId: number | null = null) => {
const params = new URLSearchParams()
// ページ番号をパラメータに追加(1ページ目以外)
if (newPage > 1) {
params.set('page', newPage.toString())
}
// 記事IDをパラメータに追加(選択されている場合)
if (newArticleId) {
params.set('article', newArticleId.toString())
}
// URLを更新(ブラウザの履歴に追加)
const queryString = params.toString()
const newUrl = queryString ? `?${queryString}` : '/benkyo/paging'
router.push(newUrl)
}
/**
* ページ変更時のハンドラー(URL同期版)
*
* @param page - 変更先のページ番号
*/
const handlePageChange = (page: number) => {
// URLを更新(記事選択は解除)
updateUrl(page, null)
}
/**
* 記事選択時のハンドラー(URL同期版)
*
* @param articleId - 選択する記事のID
*/
const handleArticleSelect = (articleId: number) => {
// URLを更新(現在のページを維持、記事IDを追加)
updateUrl(currentPage, articleId)
}
// カスタムフックの戻り値
return {
currentPage,
selectedArticleId,
paginationInfo,
currentPageArticles,
adjacentArticles,
pageNumbers,
handlePageChange,
handleArticleSelect
}
}
/**
* 記事データの型定義
*/
export type Article = {
id: number // 記事の一意識別子
title: string // 記事のタイトル
content: string // 記事の内容
date: string // 記事の投稿日(YYYY-MM-DD形式)
}
/**
* ページング情報の型定義
* ページングに必要な全ての情報を含む
*/
export type PaginationInfo = {
currentPage: number // 現在のページ番号(1から始まる)
totalPages: number // 総ページ数
totalItems: number // 総データ件数
itemsPerPage: number // 1ページあたりの表示件数
hasNext: boolean // 次のページがあるかどうか
hasPrev: boolean // 前のページがあるかどうか
nextPage: number | null // 次のページ番号(なければnull)
prevPage: number | null // 前のページ番号(なければnull)
startIndex: number // 現在のページの開始インデックス(0から始まる)
endIndex: number // 現在のページの終了インデックス(0から始まる)
}
/**
* 選択記事の前後の記事の型定義
*/
export type AdjacentArticles = {
next: Article | null // 次の記事(なければnull)
prev: Article | null // 前の記事(なければnull)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment