Build a Pagination Component
Learn how to build pagination from scratch — page numbers, Prev/Next controls, ellipsis for large ranges, and wiring it to a paginated API.
Table of Contents
Problem Statement
Build a pagination component that supports:
- Fetching a page of data from a paginated API
- Prev / Next buttons, disabled at boundaries
- Numbered page buttons, current page highlighted
- Ellipsis (…) so large page counts don't overflow
- Always showing first and last page numbers
- Showing a results count like "Showing 1–8 of 87"
Why this question?
Pagination tests state management, derived computations (the ellipsis algorithm), conditional rendering, and API integration — all in a compact, real-world component you'll find on nearly every data-heavy UI.
State & Data Shape
You only need two pieces of state:
const [currentPage, setCurrentPage] = useState(1);
const [result, setResult] = useState<PageResult | null>(null);
const [loading, setLoading] = useState(false);
// PageResult shape returned by the API
interface PageResult {
data: User[]; // items for the current page
total: number; // total records
page: number; // current page
pageSize: number; // items per page
totalPages: number; // Math.ceil(total / pageSize)
}
Derive, don't store
Never store totalPages separately — it comes from the API response. Derive everything else (like the results label) from result and currentPage at render time.
Fetching & Page Changes
Fetch whenever currentPage changes. A goTo helper keeps the boundary check in one place:
useEffect(() => {
const load = async () => {
setLoading(true);
const data = await fetchUsers(currentPage);
setResult(data);
setLoading(false);
};
load();
}, [currentPage]);
const goTo = (page: number) => {
if (!result) return;
if (page < 1 || page > result.totalPages) return;
setCurrentPage(page);
};
// "Showing 9–16 of 87 users"
const from = (currentPage - 1) * result.pageSize + 1;
const to = Math.min(currentPage * result.pageSize, result.total);
`Showing ${from}–${to} of ${result.total} users`
Ellipsis Algorithm
The trickiest part. The goal is a compact range like 1 … 4 5 6 … 12 instead of listing all 12 pages.
function getPageNumbers(
currentPage: number,
totalPages: number,
): (number | "...")[] {
// Always show all pages if there aren't many
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i + 1);
}
const pages: (number | "...")[] = [];
// Window of ±2 around current page
const left = Math.max(2, currentPage - 2);
const right = Math.min(totalPages - 1, currentPage + 2);
pages.push(1);
if (left > 2) pages.push("...");
for (let i = left; i <= right; i++) pages.push(i);
if (right < totalPages - 1) pages.push("...");
pages.push(totalPages);
return pages;
}
Rendering the result
When mapping over the array, check typeof item === "number" to decide whether to render a button or a <span>…</span>. Use key={`page-${item}-${i}`} to avoid key collisions when two "..." entries exist.
Accessibility
- Wrap the pagination in <nav aria-label="Pagination">
- Add aria-current="page" to the active page button
- Label Prev/Next with aria-label="Previous page" / "Next page"
- Use aria-disabled instead of the HTML disabled attribute on <a> tags
- Ellipsis spans should have aria-hidden="true" — they carry no semantic meaning
Interview Follow-up Questions
Q:How would you handle a URL-driven pagination (page in query params)?
A: Use Next.js searchParams or the router to read/write the page param. On mount, initialize currentPage from the URL. On goTo, use router.push with the updated param. This makes pages bookmarkable and back-button friendly.
Q:How do you avoid a flash of stale data when changing pages?
A: Keep the old data visible while loading (don't clear result on page change). Show a subtle loading indicator instead. Only replace result once the new fetch resolves. This is the same pattern used by React Query's keepPreviousData option.
Q:How would you add a page size selector?
A: Add a pageSize state variable (default 8). Reset currentPage to 1 whenever pageSize changes. Pass pageSize to fetchUsers. Render a <select> with options like 8, 16, 32 that calls setPageSize on change.
Ready to build it yourself?
The table and Prev/Next buttons are wired up. Implement getPageNumbers with ellipsis logic and replace the page label with mapped page number buttons.