Published on

Building a Reusable Pagination Component in React From Scratch

Authors

'Building a Reusable Pagination Component in React From Scratch'

A comprehensive, step-by-step guide to building a flexible, accessible, and reusable pagination component from the ground up using React, complete with best practices and advanced features.

Table of Contents

Dealing with large datasets is a common challenge in web development. Whether you're displaying a product catalog, a list of articles, or search results, loading everything at once is a recipe for a slow, clunky user experience. The solution? Pagination.

Pagination is the elegant practice of dividing content into discrete pages. It's a cornerstone of good UI/UX, improving performance and making information much more digestible for your users. While many UI libraries offer pre-built pagination components, building one from scratch is an excellent way to deepen your understanding of React, state management, and component architecture.

In this comprehensive guide, we'll walk through the entire process of building a simple, yet powerful and reusable pagination component in React. We'll start with the core logic, build the UI, integrate it into a sample application, and finally, cover advanced features like accessibility and handling large page ranges.

Let's get started!

Why Bother with Pagination?

Before we write a single line of code, it's crucial to understand why pagination is so important. It's not just about adding 'Next' and 'Previous' buttons; it's about solving fundamental problems.

  1. Performance: Imagine an e-commerce store with 10,000 products. Fetching and rendering all 10,000 items on the initial page load would be disastrous. Your app would feel sluggish, consume excessive memory, and lead to high bounce rates. Pagination allows you to fetch only a small, manageable subset of data (e.g., 25 products) for each page, resulting in lightning-fast initial load times.

  2. User Experience (UX): A wall of infinite data can be overwhelming. Pagination provides structure and a sense of control. Users can easily gauge the amount of content, navigate to specific sections (like 'page 5'), and bookmark their location. It creates a predictable and organized browsing experience.

  3. Server Health: By requesting data in smaller chunks, you reduce the load on your backend servers and database. This leads to a more stable and scalable application architecture.

While alternatives like 'Infinite Scroll' and 'Load More' buttons have their place, traditional pagination remains the gold standard for content where users need to locate specific items or navigate between known sections, such as in documentation, e-commerce catalogs, or administrative dashboards.

Section 1: The Core Logic - It's All About the Math

Great components are built on solid logic. Before we even think about JSX, let's break down the essential data points and calculations needed for any pagination system. The beauty of this logic is that it's framework-agnostic—it's pure JavaScript.

We need three key pieces of information to drive our component:

  • currentPage: The page the user is currently viewing. (e.g., 1, 5, 10)
  • totalItems: The total number of items in our entire dataset. (e.g., 157 blog posts)
  • itemsPerPage: The maximum number of items we want to display on a single page. (e.g., 10)

From these three inputs, we can calculate everything else we need, most importantly, the totalPages.

const totalItems = 157;
const itemsPerPage = 10;

// The total number of pages is the total items divided by items per page.
// We use Math.ceil() to ensure we have a page for any remaining items.
// 157 / 10 = 15.7, which we round up to 16 pages.
const totalPages = Math.ceil(totalItems / itemsPerPage);

console.log(totalPages); // Output: 16

Using Math.ceil() is critical. If we used Math.floor(), we would only have 15 pages, and the last 7 items would never be accessible. Always round up!

With totalPages calculated, we have the foundation for our component. Now, let's translate this logic into a React component.

Section 2: Building the Component Scaffolding

Let's create our basic Pagination component file. We'll design it to be a "dumb" or presentational component. This means it won't manage its own state; instead, it will receive all the data it needs via props and communicate user actions back to its parent component. This makes it highly reusable.

Create a new file: src/components/Pagination.js

import React from 'react';

const Pagination = ({ currentPage, totalPages, onPageChange }) => {
  // If there's only one page, we don't need to show the pagination controls
  if (totalPages <= 1) {
    return null;
  }

  // We'll add the page number logic here soon
  const pageNumbers = [];
  for (let i = 1; i <= totalPages; i++) {
    pageNumbers.push(i);
  }

  return (
    <nav aria-label="Page navigation">
      <ul className="pagination">
        {/* Previous Button */}
        <li className={`page-item ${currentPage === 1 ? 'disabled' : ''}`}>
          <button 
            className="page-link" 
            onClick={() => onPageChange(currentPage - 1)}
            disabled={currentPage === 1}
          >
            Previous
          </button>
        </li>

        {/* Page Numbers */}
        {pageNumbers.map(number => (
          <li key={number} className={`page-item ${currentPage === number ? 'active' : ''}`}>
            <button 
              className="page-link" 
              onClick={() => onPageChange(number)}
            >
              {number}
            </button>
          </li>
        ))}

        {/* Next Button */}
        <li className={`page-item ${currentPage === totalPages ? 'disabled' : ''}`}>
          <button 
            className="page-link" 
            onClick={() => onPageChange(currentPage + 1)}
            disabled={currentPage === totalPages}
          >
            Next
          </button>
        </li>
      </ul>
    </nav>
  );
};

export default Pagination;

Let's break down this initial structure:

  • Props: We're accepting currentPage, totalPages, and a callback function onPageChange. This function is the key to communication. When a user clicks a page number, our component doesn't change the page itself; it tells the parent component, "Hey, the user wants to go to page X!"
  • Early Return: If totalPages is 1 or less, there's no need for pagination controls, so we simply return null.
  • Page Numbers Array: A simple for loop generates an array of numbers from 1 to totalPages.
  • Rendering: We map over the pageNumbers array to render a button for each page. The active class is conditionally applied to highlight the current page.
  • Previous/Next Buttons: These buttons are crucial for easy navigation. They call onPageChange with the appropriate next or previous page number. We also add the disabled attribute and a corresponding class when the user is on the first or last page to prevent invalid navigation.
  • Accessibility Hint: We've wrapped our <ul> in a <nav> element with an aria-label to provide context for screen reader users.

Section 3: Integrating the Pagination Component

A component is useless in isolation. Let's see how to use our new Pagination component within a parent that manages the data and state.

Imagine we have a parent component, ItemsList, that fetches and displays a list of items.

Create a new file: src/components/ItemsList.js

import React, { useState, useMemo } from 'react';
import Pagination from './Pagination';

// Let's create some mock data
const allItems = Array.from({ length: 157 }, (_, i) => ({ 
  id: i + 1, 
  name: `Item ${i + 1}` 
}));

const ITEMS_PER_PAGE = 10;

const ItemsList = () => {
  const [currentPage, setCurrentPage] = useState(1);

  const totalPages = Math.ceil(allItems.length / ITEMS_PER_PAGE);

  // useMemo will re-calculate the sliced data only when currentPage or allItems changes.
  const currentItems = useMemo(() => {
    const firstPageIndex = (currentPage - 1) * ITEMS_PER_PAGE;
    const lastPageIndex = firstPageIndex + ITEMS_PER_PAGE;
    return allItems.slice(firstPageIndex, lastPageIndex);
  }, [currentPage]);

  const handlePageChange = (page) => {
    setCurrentPage(page);
  };

  return (
    <div className="items-container">
      <h1>My Awesome List of Items</h1>
      
      {/* List of items for the current page */}
      <ul className="item-list">
        {currentItems.map(item => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>

      {/* Our awesome pagination component */}
      <Pagination 
        currentPage={currentPage}
        totalPages={totalPages}
        onPageChange={handlePageChange}
      />
    </div>
  );
};

export default ItemsList;

Here's what's happening in the parent component:

  1. State Management: We use useState to keep track of the currentPage. This is the single source of truth for our pagination state.
  2. Data Slicing: This is where the magic happens. We calculate the firstPageIndex and lastPageIndex based on the currentPage and ITEMS_PER_PAGE. Then, we use the Array.prototype.slice() method to extract just the items for the current page. We wrap this logic in useMemo as a performance optimization, ensuring the slicing only re-runs when currentPage changes.
  3. handlePageChange: This function is passed down to our Pagination component as the onPageChange prop. When a user clicks a page button, our Pagination component calls this function, which then updates the currentPage state in ItemsList. This state update triggers a re-render, and our useMemo hook provides the new currentItems for display.
  4. Rendering: We render the list of currentItems and, right below it, our Pagination component, feeding it the props it needs to work.

Now you have a fully functional, client-side pagination system! But we can make it even better.

Section 4: Advanced Features & Best Practices

Our current component is great, but it has a major flaw: what if we have 100 pages? Or 1,000? Rendering a button for every single page would create a new UI problem. We need to be smarter.

1. Handling Large Page Ranges with Ellipses (...)

The standard solution is to show the first page, the last page, a few pages around the current page, and use ellipses (...) to represent the pages in between.

This logic can get complex, so it's a perfect candidate for a custom hook. Let's create usePagination.

Create a new file: src/hooks/usePagination.js

import { useMemo } from 'react';

export const DOTS = '...';

const range = (start, end) => {
  let length = end - start + 1;
  return Array.from({ length }, (_, idx) => idx + start);
};

export const usePagination = ({ totalPages, siblingCount = 1, currentPage }) => {
  const paginationRange = useMemo(() => {
    // Our implementation logic will go here

    // 1. Determine the total number of page numbers to show.
    // It's siblingCount + firstPage + lastPage + currentPage + 2*DOTS
    const totalPageNumbers = siblingCount + 5;

    // Case 1: If the number of pages is less than the page numbers we want to show,
    // we return the range [1..totalPages].
    if (totalPageNumbers >= totalPages) {
      return range(1, totalPages);
    }

    // 2. Calculate left and right sibling index and make sure they are within range [1, totalPages]
    const leftSiblingIndex = Math.max(currentPage - siblingCount, 1);
    const rightSiblingIndex = Math.min(currentPage + siblingCount, totalPages);

    // We do not show dots just when there is one page number to be inserted between the extremes of sibling and the page limits i.e 1 and totalPages. 
    // Hence, we are using leftSiblingIndex > 2 and rightSiblingIndex < totalPages - 2
    const shouldShowLeftDots = leftSiblingIndex > 2;
    const shouldShowRightDots = rightSiblingIndex < totalPages - 2;

    const firstPageIndex = 1;
    const lastPageIndex = totalPages;

    // Case 2: No left dots to show, but rights dots to be shown
    if (!shouldShowLeftDots && shouldShowRightDots) {
      let leftItemCount = 3 + 2 * siblingCount;
      let leftRange = range(1, leftItemCount);

      return [...leftRange, DOTS, totalPages];
    }

    // Case 3: No right dots to show, but left dots to be shown
    if (shouldShowLeftDots && !shouldShowRightDots) {
      let rightItemCount = 3 + 2 * siblingCount;
      let rightRange = range(totalPages - rightItemCount + 1, totalPages);
      return [firstPageIndex, DOTS, ...rightRange];
    }

    // Case 4: Both left and right dots to be shown
    if (shouldShowLeftDots && shouldShowRightDots) {
      let middleRange = range(leftSiblingIndex, rightSiblingIndex);
      return [firstPageIndex, DOTS, ...middleRange, DOTS, lastPageIndex];
    }

  }, [totalPages, siblingCount, currentPage]);

  return paginationRange;
};

This hook is a bit dense, but the logic is sound. It takes our pagination config and returns a memoized array representing the exact page numbers and dots to render. Now, let's update our Pagination.js to use it.

Updated Pagination.js:

// At the top of Pagination.js
import { usePagination, DOTS } from '../hooks/usePagination';

// Inside the Pagination component, replace the old pageNumbers logic:
const paginationRange = usePagination({
  currentPage,
  totalPages,
  siblingCount: 1 // You can make this a prop to be more flexible
});

// If there are less than 2 times in pagination range we shall not render the component
if (currentPage === 0 || paginationRange.length < 2) {
  return null;
}

// In the return statement, update the mapping logic
return (
  <nav aria-label="Page navigation">
    <ul className="pagination">
      {/* ... Previous Button ... */}
      
      {paginationRange.map((pageNumber, index) => {
        if (pageNumber === DOTS) {
          return <li key={`dots-${index}`} className="page-item dots">&#8230;</li>;
        }

        return (
          <li key={pageNumber} className={`page-item ${currentPage === pageNumber ? 'active' : ''}`}>
            <button className="page-link" onClick={() => onPageChange(pageNumber)}>
              {pageNumber}
            </button>
          </li>
        );
      })}

      {/* ... Next Button ... */}
    </ul>
  </nav>
);

By abstracting this complex logic into a custom hook, our Pagination component remains clean, readable, and focused on rendering.

2. Enhancing Accessibility (a11y)

An accessible component is a professional component. We've already added a <nav> wrapper, but we can do more.

  • aria-current: This attribute is more specific than a class for indicating the current page. Screen readers will announce it as "Current Page".
  • aria-disabled: While the disabled HTML attribute works, aria-disabled="true" can be more flexible, especially if you're using <a> tags instead of <button>s and want to prevent navigation while keeping the element focusable.
  • Clear Labels: Ensure your 'Previous' and 'Next' buttons have clear, descriptive text. You can use aria-label to add more context, e.g., aria-label="Go to previous page".

Here's a snippet with improved accessibility:

// Inside the page number mapping
<li /* ... */>
  <button
    // ...
    aria-current={currentPage === pageNumber ? 'page' : undefined}
  >
    {pageNumber}
  </button>
</li>

// For the Next/Previous buttons
<button
  // ...
  disabled={currentPage === 1}
  aria-disabled={currentPage === 1}
  aria-label="Go to previous page"
>
  Previous
</button>

3. Styling Your Component

Styling is subjective, but a good strategy is to provide a clean, semantic class structure that's easy to override. Our component already does this with classes like .pagination, .page-item, .page-link, .active, and .disabled.

Here's some basic CSS to get you started. You can adapt this to your project's design system or CSS-in-JS solution.

.pagination {
  display: flex;
  list-style: none;
  padding: 0;
  justify-content: center;
  margin-top: 1rem;
}

.page-item {
  margin: 0 4px;
}

.page-link {
  border: 1px solid #ddd;
  padding: 8px 16px;
  color: #007bff;
  background-color: #fff;
  cursor: pointer;
  border-radius: 4px;
  transition: all 0.2s ease-in-out;
}

.page-link:hover {
  background-color: #f4f4f4;
}

.page-item.active .page-link {
  background-color: #007bff;
  color: white;
  border-color: #007bff;
  font-weight: bold;
}

.page-item.disabled .page-link {
  color: #6c757d;
  pointer-events: none;
  cursor: default;
  background-color: #fff;
  border-color: #dee2e6;
}

.page-item.dots {
  align-self: flex-end;
  padding: 8px 4px;
}

Conclusion

Congratulations! You've successfully built a robust, reusable, and accessible pagination component from scratch. We've journeyed from the core mathematical logic to a fully integrated React component, and even tackled advanced challenges like handling large page sets with a custom hook.

Let's recap the key takeaways:

  • Start with Logic: Understand the core calculations (totalPages) before writing any UI code.
  • Embrace Props and Callbacks: Design components to be controlled by their parents (onPageChange) for maximum reusability.
  • Separate Concerns: Abstract complex, reusable logic into custom hooks (usePagination) to keep your components clean and focused.
  • Prioritize Accessibility: Use semantic HTML and ARIA attributes (nav, aria-current, aria-label) to ensure your component is usable by everyone.

From here, you can extend this component even further. You could add a 'go-to-page' input field, allow the user to change the number of items per page, or animate the page transitions. The foundation you've built is solid and ready for whatever features you dream up next. Happy coding!