ReactStateUXNavigation

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.

20 min read6 sections
01

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.

02

State & Data Shape

You only need two pieces of state:

statetypescript
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.

03

Fetching & Page Changes

Fetch whenever currentPage changes. A goTo helper keeps the boundary check in one place:

fetchingtypescript
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);
};
results labeltypescript
// "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`
04

Ellipsis Algorithm

The trickiest part. The goal is a compact range like 1 … 4 5 6 … 12 instead of listing all 12 pages.

getPageNumberstypescript
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.

05

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
06

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.