How to Build a Pagination Component in React

Intermediate10 min interview
Skills tested:
Managing current page state and deriving the visible slice without storing it in stateComputing totalPages correctly and guarding against edge cases (empty data, zero items)Disabling Previous and Next buttons at the boundariesGenerating page number buttons and applying an active stateResetting to page 1 when the data source or filter changes

Advertisement

🧩 Scenario

Pagination is a common UI challenge question. Interviewers look for whether you derive the slice and totalPages from state (not store them), whether you handle boundary conditions (first and last page), and whether you know when to move pagination to the server.

Architecture Walkthrough

Derive, Do Not Store

The only piece of state a pagination component needs is currentPage. Everything else is a derived value computed during render: the start index, the end index, the visible slice of data, and the total number of pages. Storing these in state and syncing them with useEffect is a common mistake that causes stale data bugs when the underlying array changes. React's rendering model is synchronous and predictable: derive once per render from the single source of truth (currentPage and the data array).

totalPages = Math.ceil(items.length / pageSize). Using Math.floor is a bug: a 10-item array with 3 items per page would give 3 pages, dropping the last item on page 4. Math.ceil gives 4 pages, which is correct.

Boundary Handling

Navigation must be disabled at both ends. currentPage === 1 disables the Previous button. currentPage === totalPages disables the Next button. Skipping this lets users navigate to page 0 or beyond the last page, producing either an empty slice or a crash when startIndex is negative. A defensive implementation also clamps currentPage using Math.max(1, Math.min(page, totalPages)) in the set handler, so programmatic calls with out-of-range values are safe.

Resetting on Data Change

When the data array changes (new search results, filter applied), the user may be on a page that no longer exists. A useEffect watching the data source resets currentPage to 1 whenever the data changes. Without this, a user on page 5 of 500 filtered results navigates to a search term with 20 results (2 pages), and the component tries to render page 5 of 2, showing an empty slice with no indication of why.


Key Code Explained

import { useState, useEffect, useMemo } from 'react';

interface PaginationProps<T> {
  items: T[];
  pageSize?: number;
  renderItem: (item: T, index: number) => React.ReactNode;
}

function Pagination<T>({ items, pageSize = 10, renderItem }: PaginationProps<T>) {
  const [currentPage, setCurrentPage] = useState(1);

  // Reset to page 1 whenever the data source changes
  useEffect(() => {
    setCurrentPage(1);
  }, [items]);

  // Derived values — never stored in state
  const totalPages = Math.ceil(items.length / pageSize) || 1;
  const startIndex = (currentPage - 1) * pageSize;
  const visibleItems = items.slice(startIndex, startIndex + pageSize);

  const goToPage = (page: number) => {
    setCurrentPage(Math.max(1, Math.min(page, totalPages)));
  };

  return (
    <div>
      {/* Render the visible slice */}
      <ul>
        {visibleItems.map((item, i) => (
          <li key={startIndex + i}>{renderItem(item, startIndex + i)}</li>
        ))}
      </ul>

      {/* Navigation controls */}
      <nav aria-label="Pagination">
        <button
          onClick={() => goToPage(currentPage - 1)}
          disabled={currentPage === 1}
          aria-label="Previous page"
        >
          Previous
        </button>

        {/* Page number buttons */}
        {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
          <button
            key={page}
            onClick={() => goToPage(page)}
            aria-current={page === currentPage ? 'page' : undefined}
            className={page === currentPage ? 'active' : ''}
          >
            {page}
          </button>
        ))}

        <button
          onClick={() => goToPage(currentPage + 1)}
          disabled={currentPage === totalPages}
          aria-label="Next page"
        >
          Next
        </button>
      </nav>

      <p aria-live="polite">
        Page {currentPage} of {totalPages} ({items.length} items)
      </p>
    </div>
  );
}


// Server-side pagination: page state controls the API request
function ServerPaginatedList({ pageSize = 10 }: { pageSize?: number }) {
  const [currentPage, setCurrentPage] = useState(1);

  const { data, isLoading, totalCount } = useProducts({
    page: currentPage,
    limit: pageSize,
  });

  const totalPages = Math.ceil((totalCount ?? 0) / pageSize);

  if (isLoading) return <Skeleton />;

  return (
    <div>
      {data?.map((product) => <ProductCard key={product.id} product={product} />)}
      <PaginationControls
        currentPage={currentPage}
        totalPages={totalPages}
        onPageChange={setCurrentPage}
      />
    </div>
  );
}

The aria-current="page" attribute on the active page button is the correct accessibility pattern for indicating the current page to screen readers. The aria-live="polite" on the status paragraph announces page changes without interrupting the user.


Tradeoffs

ApproachWhen to useData requirement
Client-side slicingSmall datasets already in memoryAll items loaded upfront
Server-side paginationLarge datasets; unknown total countAPI accepts page + limit params
Cursor-based paginationInfinite scroll; real-time dataAPI returns next cursor token

What Interviewers Actually Check

  • Whether you derive visibleItems and totalPages during render rather than storing them in state
  • Whether you use Math.ceil for totalPages
  • Whether you disable navigation at the first and last pages
  • Whether you reset to page 1 when the data source changes
  • Whether you know when server-side pagination is the right choice

Follow-Up Questions

  1. How would you add keyboard navigation so that left/right arrow keys move between pages?
  2. How would you truncate page number buttons with ellipsis when there are more than 7 pages (e.g., 1 2 3 ... 18 19 20)?
  3. How would you sync the current page to the URL query parameter so the user can share a link to a specific page?
  4. Describe the UX and implementation difference between traditional pagination and infinite scroll. When is each appropriate?
  5. How does React Query handle server-side pagination with keepPreviousData and why is it important for UX?

Common Candidate Mistakes

  • Storing visibleItems in state and syncing with useEffect — creates a one-render delay and stale data bugs
  • Using Math.floor instead of Math.ceil for totalPages — drops the last partial page
  • Forgetting to disable navigation buttons at the boundaries — allows navigating to page 0 or beyond
  • Not resetting currentPage when the data array changes — user stays on a nonexistent page after filtering
  • Rendering all items and hiding them with CSS rather than slicing — defeats the performance purpose of pagination

Interview Readiness Checklist

Before you leave this question, make sure you can answer:

  • Can you compute startIndex, endIndex, and visibleItems from currentPage and pageSize without using state?
  • Can you compute totalPages with Math.ceil and explain why floor is wrong?
  • Can you disable Prev at page 1 and Next at the last page?
  • Can you render page number buttons and highlight the current one with aria-current?
  • Can you reset to page 1 when the items array changes?

Summary

A pagination component needs one piece of state: currentPage. All other values (startIndex, visibleItems, totalPages) are derived during render. Compute totalPages with Math.ceil(items.length / pageSize). Slice the data array to get the visible items for the current page. Disable the Previous button when currentPage === 1 and the Next button when currentPage === totalPages. Reset currentPage to 1 in a useEffect when the data array changes so the user never ends up on a nonexistent page. For large datasets, move pagination to the server and pass the page number as a query parameter to the API.

Related Videos
Watch these videos to learn more about this topic
Frequently Asked Questions

Should I paginate on the frontend or the backend?

For small, already-loaded datasets (under a few hundred items), client-side slicing is fine. For large datasets, paginate via API: send the current page and page size as query params, and the server returns only that slice. Client-side pagination of 10,000 rows wastes bandwidth and memory loading data the user may never see.

Advertisement


Stay Updated

Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.

Advertisement