Reactで、ページ番号付きの一覧を作る方法
React でページ番号付きの一覧を作るときは、今のページ番号だけを state に持ちます。表示する10件と、並べるページ番号のボタンは、描画のたびにそこから計算します。
検証環境で95件の一覧を作ってブラウザで操作したところ、計算の部分よりも、キーボードで操作したときのフォーカスに気を付ける必要がありました。ボタンの key に配列の番号を使うと、押したボタンとは別の番号にフォーカスが移りました。最後のページで「次へ」を disabled にすると、フォーカスがどのボタンにもない状態になりました。
検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2 です。.tsx を tsc で型チェックしてから esbuild で変換し(開発用のビルド、StrictMode あり)、Chromium 153 を Playwright 1.63.0 で操作して確かめました。データは画面の中に持った95件の配列で、ページごとにサーバーから取得する形や、ページ番号を URL に持たせる形は試していません。最後の節で触れる、Pagination に state の page をそのまま渡す形も、動かしてはいません。スクリーンリーダーでの実際の読み上げも試しておらず、確かめたのは Playwright で取り出したアクセシビリティツリーの内容までです。

表示する10件は、配列の slice で切り出す
一覧の部品 CatList の全体は次のとおりです。state は3つで、猫の配列、毛の色の絞り込み、今のページ番号です。全部で何ページあるか、今のページに出す10件はどれかは、state には入れずに描画のたびに計算します。
import { useState } from 'react';
import { COLORS, type Cat, type Color } from './cats';
import { Pagination } from './Pagination';
const PAGE_SIZE = 10;
export function CatList({ initialCats }: { initialCats: Cat[] }) {
const [cats, setCats] = useState(initialCats);
const [color, setColor] = useState<Color | 'all'>('all');
const [page, setPage] = useState(1);
const filtered = color === 'all' ? cats : cats.filter((cat) => cat.color === color);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
// 件数が減って page が最後のページを超えても、ないページは表示しない
const currentPage = Math.min(page, totalPages);
const start = (currentPage - 1) * PAGE_SIZE;
const visible = filtered.slice(start, start + PAGE_SIZE);
const end = start + visible.length;
function changeColor(next: Color | 'all') {
setColor(next);
setPage(1); // 条件を変えたら1ページ目に戻す
}
function remove(id: number) {
setCats(cats.filter((cat) => cat.id !== id));
}
return (
<section>
<label>
毛の色
<select value={color} onChange={(e) => changeColor(e.target.value as Color | 'all')}>
<option value="all">すべて</option>
{COLORS.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</label>
<p className="summary">
{filtered.length === 0
? '0件'
: `${start + 1 === end ? end : `${start + 1}〜${end}`}件目/全${filtered.length}件`}
</p>
<ul className="cats">
{visible.map((cat) => (
<li key={cat.id}>
{cat.name}({cat.color})
<button type="button" onClick={() => remove(cat.id)} aria-label={`${cat.name}を削除`}>
削除
</button>
</li>
))}
</ul>
<Pagination current={currentPage} total={totalPages} onChange={setPage} />
</section>
);
}
slice(start, end) は end の位置を含まないので、start + PAGE_SIZE を渡せば10件になります。最後のページで残りが10件に足りなくても、あるだけを返します。95件の一覧では、10ページ目が「91〜95件目」の5件になりました。
総ページ数を Math.max(1, …) にしているのは、絞り込みで0件になったときも「1ページ目」として扱うためです。0ページにすると、ページ番号のボタンが1つもなくなり、currentPage も0になってしまいます。
公式ドキュメントのほかの props や state から計算できる値についての説明にあるとおり、そうした値は state に入れず、描画の中で計算します。ページの10件を useState に入れて useEffect で詰め直す書き方にすると、ページ番号と中身がずれる余地ができます。
ページ番号は、最初と最後、今のページの前後だけを出す
すべての番号を並べると、ページが増えるだけボタンも増えていきます。そこで8ページ以上のときは、最初と最後のページ、今のページとその前後1つだけを出し、間は「…」にします。
export type PageItem = number | 'start-ellipsis' | 'end-ellipsis';
const range = (from: number, to: number) =>
Array.from({ length: to - from + 1 }, (_, i) => from + i);
// 表示するページ番号の並び。最初と最後、今のページと前後1つを出し、間は「…」にする。
// total が7以上のときは、いつも7個になる(ボタンの数が変わらない)
export function getPageItems(current: number, total: number): PageItem[] {
if (total <= 7) return range(1, total);
if (current <= 4) return [...range(1, 5), 'end-ellipsis', total];
if (current >= total - 3) return [1, 'start-ellipsis', ...range(total - 4, total)];
return [1, 'start-ellipsis', current - 1, current, current + 1, 'end-ellipsis', total];
}
先頭や末尾に近いページで5つ続けて出しているのは、並びをいつも7個にそろえるためです。ページを移るたびにボタンの数が変わると、押そうとしていたボタンの位置がずれます。もう1つの理由は、「…」が1ページだけを隠す並びを作らないためです。「1 … 3 4 5」のように2だけを隠すなら、「…」の代わりに「2」を出しても幅は変わりません。
「…」を2種類の文字列にしているのは、あとで React の key に使うためです。前と後ろの「…」が同じ値だと、key が重なります。
20ページのとき、今のページによって並びは次のように変わりました(テストの出力のうち、総ページ数5の行は省いています)。
current=1 total=20: 1 2 3 4 5 … 20
current=4 total=20: 1 2 3 4 5 … 20
current=5 total=20: 1 … 4 5 6 … 20
current=10 total=20: 1 … 9 10 11 … 20
current=16 total=20: 1 … 15 16 17 … 20
current=17 total=20: 1 … 16 17 18 19 20
current=20 total=20: 1 … 16 17 18 19 20
1275 通りの組み合わせで、すべての決まりを満たした
最後の行は、総ページ数1〜50と、そのそれぞれのすべてのページ(1,275通り)で、決まりを満たすかを確かめた結果です。確かめた決まりは、最初・最後・今のページが必ず入ること、番号が重ならずに小さい順に並ぶこと、7ページ以上ならいつも7個になること、「…」が2ページ以上を隠すときだけ出ることの4つです。
ボタンの key には、ページ番号を使う
ページ番号のボタンを並べる部品です。<nav> に aria-label で「ページ」という名前を付け、今のページのボタンには aria-current="page" を付けます。MDN の aria-current の説明では、ページ番号のリンクのうち、今のページを見た目で区別しているものに aria-current="page" を付けるよう書かれています。見た目の色の違いを、スクリーンリーダーなどの支援技術にも伝えるためです。
import { getPageItems } from './pageItems';
type Props = {
current: number;
total: number;
onChange: (page: number) => void;
};
export function Pagination({ current, total, onChange }: Props) {
const isFirst = current === 1;
const isLast = current === total;
return (
<nav aria-label="ページ" className="pagination">
{/* disabled にするとフォーカスが外れるので、aria-disabled にして押されても何もしない */}
<button type="button" aria-disabled={isFirst} onClick={() => !isFirst && onChange(current - 1)}>
前へ
</button>
<ul>
{getPageItems(current, total).map((item) =>
typeof item === 'number' ? (
<li key={item}>
<button
type="button"
aria-label={`${item}ページ`}
aria-current={item === current ? 'page' : undefined}
onClick={() => onChange(item)}
>
{item}
</button>
</li>
) : (
<li key={item} aria-hidden="true">
…
</li>
),
)}
</ul>
<button type="button" aria-disabled={isLast} onClick={() => !isLast && onChange(current + 1)}>
次へ
</button>
</nav>
);
}
番号のボタンには aria-label で「5ページ」のような名前を付けました。数字の「5」だけでは、何の5かが伝わりにくいためです。「…」は押せない飾りなので、aria-hidden で支援技術からは隠しています。
Playwright でこの部品のアクセシビリティツリーを取り出すと、次のようになりました。95件・10ページの一覧の、最初の表示です。「…」の項目は出てこず、1ページ目では「前へ」が操作できない状態([disabled])として出ています。
- navigation "ページ":
- button "前へ" [disabled]
- list:
- listitem:
- button "1ページ": "1"
- listitem:
- button "2ページ": "2"
- listitem:
- button "3ページ": "3"
- listitem:
- button "4ページ": "4"
- listitem:
- button "5ページ": "5"
- listitem:
- button "10ページ": "10"
- button "次へ"
key に配列の番号を使うと、フォーカスが隣の番号に移る
key に item(ページ番号)ではなく、map の2つ目の引数(配列の中の位置)を使った版も作り、比べました。部品の名前のほかに変えたのは、map の引数に index を足し、2か所の key をそれに替えたところだけです。20ページの一覧で、キーボードで「5」にフォーカスを合わせて Enter を押した結果です。
1 … 4 5 6 … 20 | 5 aria-current=page | フォーカス: button "6"
もう一度 Enter: 1 … 5 6 7 … 20 | 6 aria-current=page | フォーカス: button "7"
5ページ目には移りましたが、フォーカスは「6」のボタンにありました。並びが「1 2 3 4 5 … 20」から「1 … 4 5 6 … 20」に変わり、配列の5番目の位置にあるボタンの数字が「5」から「6」に変わったためです。React は同じ key の <li> を同じ要素として使い回すので、中のボタンはフォーカスを保ったまま、表示だけが「6」に書き換わります。続けて Enter を押すと、意図していない6ページ目に移りました。
同じ20ページの一覧で、key にページ番号を使った上のコードでも試しました。
1 … 4 5 6 … 20 | 5 aria-current=page | フォーカス: button "5"
もう一度 Enter: 1 … 4 5 6 … 20 | 5 aria-current=page | フォーカス: button "5"
こちらは、Enter を押したあともフォーカスは「5」のボタンに残りました。もう一度 Enter を押しても、5ページ目のままでした。公式の リストのレンダーの説明にも、配列の位置を key にすると、項目が増えたり減ったり並べ替えられたりしたときに、分かりにくい不具合のもとになると書かれています。ページ番号の並びは、ページを移るたびに中身が入れ替わるので、この場合に当たります。
最後のページで「次へ」を disabled にすると、フォーカスが外れる
比べるために、「前へ」と「次へ」を disabled にした版も作りました。この版の20ページの一覧で、19ページ目から、キーボードで「次へ」を押して20ページ目に移った結果です。
1 … 16 17 18 19 20 | 20 aria-current=page | 次へ disabled: true | フォーカス: body
押した「次へ」が disabled になり、フォーカスは body、つまりどのボタンにもない状態になりました。キーボードで操作している人は、今どこにいるのかが分からなくなります。
そこで、上のコードでは disabled の代わりに aria-disabled を付けています。MDN の aria-disabled の説明によると、aria-disabled は、支援技術に「操作できない」と伝えるだけで、フォーカスの順番からは外しません。その代わり、押されたときに何もしない処理と、操作できないと分かる見た目は、自分で用意する必要があります。上のコードでは、onClick の中で isLast を確かめて何もしないようにし、CSS の [aria-disabled="true"] で灰色にしました。
上のコードを使った95件・10ページの一覧で、9ページ目からキーボードで「次へ」を押した結果です。
91〜95件目/全95件 | 1 … 6 7 8 9 10 | フォーカス: button "次へ"
もう一度 Enter: 91〜95件目/全95件 | フォーカス: button "次へ"
最後のページで「次へ」を押したあとも、フォーカスは「次へ」に残りました。Enter をもう一度押しても、ページは変わりませんでした。アクセシビリティツリーでも、この「次へ」は [disabled] として出ていました。
件数が減ったら、絞り込みでは1ページ目に戻し、削除では今のページを読み替える
毛の色で絞り込むと、件数が変わり、今のページが存在しなくなることがあります。絞り込みの条件を変えたときは、1ページ目に戻すことにしました。戻す処理は、最初の節に載せた changeColor のように、条件を変えるイベントハンドラーの中で setPage(1) を一緒に呼ぶだけです。
function changeColor(next: Color | 'all') {
setColor(next);
setPage(1); // 条件を変えたら1ページ目に戻す
}
10ページ目を見ているときに毛の色を「白」にすると、「1〜10件目/全19件」の1ページ目になり、ページ番号は「1 2」になりました。
一方、1件ずつの削除では、見ているページから動かないほうが自然です。そこで remove では page に触らず、表示には Math.min(page, totalPages) で読み替えた currentPage を使っています。10ページ目の5匹を1匹ずつ削除した結果です。
1匹目を削除: 91〜94件目/全94件 | 1 … 6 7 8 9 10 | 10 aria-current=page
2匹目を削除: 91〜93件目/全93件 | 1 … 6 7 8 9 10 | 10 aria-current=page
3匹目を削除: 91〜92件目/全92件 | 1 … 6 7 8 9 10 | 10 aria-current=page
4匹目を削除: 91件目/全91件 | 1 … 6 7 8 9 10 | 10 aria-current=page
5匹目を削除: 81〜90件目/全90件 | 1 … 5 6 7 8 9 | 9 aria-current=page
4匹目を消して1件だけになったページでは、件数の表示を「91〜91件目」ではなく「91件目」にしています。5匹目を消して全90件・9ページになると、表示は9ページ目に移りました。このとき state の page は10のままで、描画の中で9に読み替えているだけです。useEffect で page を書き直す処理は要りません。
Pagination にも、state の page ではなく、読み替えたあとの currentPage を渡します。コードをたどると、page を渡した場合は、全90件・9ページになったあとも、存在しない10ページ目を今のページとして番号の並びを計算することになるためです。





