Runnable lesson · press Try it on the examples to edit and run them here.
Showing thousands of items on one page is slow and hard to use. Pagination splits a list into pages. The pattern needs only a few pieces of state and a little arithmetic.
Client-side pagination
When the whole list is already in memory, slice the page you need:
const ITEMS = Array.from({ length: 20 }, (_, i) => "Item " + (i + 1));
const PAGE_SIZE = 5;
function App() {
const [page, setPage] = React.useState(1);
const totalPages = Math.ceil(ITEMS.length / PAGE_SIZE); // derived
const start = (page - 1) * PAGE_SIZE;
const pageItems = ITEMS.slice(start, start + PAGE_SIZE); // derived
return (
<div>
<ul>{pageItems.map((item) => <li key={item}>{item}</li>)}</ul>
<nav aria-label="Pagination">
<button onClick={() => setPage(1)} disabled={page === 1}>First</button>{" "}
<button onClick={() => setPage(page - 1)} disabled={page === 1}>Previous</button>{" "}
<span>Page {page} of {totalPages}</span>{" "}
<button onClick={() => setPage(page + 1)} disabled={page === totalPages}>Next</button>{" "}
<button onClick={() => setPage(totalPages)} disabled={page === totalPages}>Last</button>
</nav>
</div>
);
}
Only page is state. totalPages and pageItems are calculated. The maths:
totalPages = Math.ceil(totalItems / pageSize)- first index of a page =
(page - 1) * pageSize slice(start, start + pageSize)gives that page’s items.
Disable Previous on the first page and Next on the last.
Reset the page when the list changes
If a search or filter changes the number of items, the current page may no longer exist. Reset to page 1 when the filter changes (in the event handler):
const ITEMS = Array.from({ length: 12 }, (_, i) => "Item " + (i + 1));
function App() {
const [page, setPage] = React.useState(1);
const [filter, setFilter] = React.useState("");
const filtered = ITEMS.filter((i) => i.toLowerCase().includes(filter.toLowerCase()));
const totalPages = Math.max(1, Math.ceil(filtered.length / 10));
const current = Math.min(page, totalPages); // guard against an out-of-range page
const pageItems = filtered.slice((current - 1) * 10, current * 10);
return (
<div>
<input aria-label="Filter" value={filter} onChange={(e) => { setFilter(e.target.value); setPage(1); }} />
<ul>{pageItems.map((i) => <li key={i}>{i}</li>)}</ul>
<button onClick={() => setPage(current - 1)} disabled={current === 1}>Previous</button>
<span> Page {current} of {totalPages} </span>
<button onClick={() => setPage(current + 1)} disabled={current === totalPages}>Next</button>
</div>
);
}
Server-side pagination
For large data sets the server returns one page at a time. The request includes the page number and size, and the response includes the items and the total:
GET /api/products?page=2&pageSize=10
{ "items": [ ... 10 products ... ], "total": 137 }
// Simulated server with 35 products
function fetchPage(page, pageSize) {
return new Promise((resolve) =>
setTimeout(() => {
const all = Array.from({ length: 35 }, (_, i) => "Product " + (i + 1));
resolve({ items: all.slice((page - 1) * pageSize, page * pageSize), total: all.length });
}, 400)
);
}
function App() {
const [page, setPage] = React.useState(1);
const [data, setData] = React.useState({ items: [], total: 0 });
const [loading, setLoading] = React.useState(true);
const pageSize = 10;
React.useEffect(() => {
let ignore = false;
setLoading(true);
fetchPage(page, pageSize).then((result) => {
if (ignore) return;
setData(result);
setLoading(false);
});
return () => { ignore = true; };
}, [page]);
const totalPages = Math.ceil(data.total / pageSize) || 1;
return (
<div aria-busy={loading}>
<p>Page {page}{loading ? " (loading...)" : ""}</p>
<ul>{data.items.map((p) => <li key={p}>{p}</li>)}</ul>
<button onClick={() => setPage(page - 1)} disabled={page === 1 || loading}>Previous</button>{" "}
<button onClick={() => setPage(page + 1)} disabled={page >= totalPages || loading}>Next</button>
</div>
);
}
Each page change runs the effect again, with stale-response protection. While a new page loads, the old items stay visible.
Cursor pagination
Some APIs return a cursor or “next” token instead of page numbers (common for feeds that change often). You keep the cursor from the last response and send it to get the next batch. Page numbers are not available, so the UI is usually Load more or infinite scroll.
Page numbers, Load more or infinite scroll?
| Pattern | Good for | Watch out for |
|---|---|---|
| Numbered pages | Catalogs and tables where users jump around; shareable URLs | More clicks |
| Previous / Next | Simple lists | No quick jump |
| Load more button | Feeds and galleries | Footer unreachable if the list never ends |
| Infinite scroll | Social feeds | Hard for keyboard and screen reader users, loses position, footers; always offer an alternative |
Accessibility and the URL
- Wrap controls in
<nav aria-label="Pagination">and mark the current page witharia-current="page"when you render page numbers. - After changing the page, tell users: move focus to the list heading, or announce “Page 2 of 4” in a
role="status"region. - Keep the page number in the URL (
?page=2), so Back, refresh and sharing work. See Route Parameters.
Key points
- Derive
totalPagesand the visible slice frompage, the page size and the list. - Reset (or clamp) the page when filters change.
- For large data, request one page at a time from the server and protect against stale responses.