Building a Custom Pagination Component in React

By Maulik Paghdal

12 Dec, 2024

•  8 minutes to Read

Building a Custom Pagination Component in React

Introduction

Pagination shows up everywhere in web apps. Whether you're displaying search results, product listings, or user comments, you'll eventually hit that moment where rendering 10,000 items crashes the browser or makes users scroll forever. That's where pagination saves the day.

Sure, you could reach for a library like react-paginate or antd, but building your own pagination component isn't just about saving bundle size. It gives you complete control over the UX, lets you handle edge cases specific to your app, and honestly, it's a solid exercise in React fundamentals.

Let's build something that actually works in production.

Project Setup

Start with a fresh React app. Nothing fancy needed here:

npx create-react-app react-pagination
cd react-pagination
npm start

If you're using Vite (and you probably should be), swap that first command for:

npm create vite@latest react-pagination -- --template react

Understanding Pagination Math

Before jumping into code, let's nail down the core logic. Pagination is essentially slicing an array based on mathematical boundaries:

// Given these values:
const totalItems = 127;
const itemsPerPage = 10;
const currentPage = 3;

// We calculate:
const totalPages = Math.ceil(totalItems / itemsPerPage); // 13
const startIndex = (currentPage - 1) * itemsPerPage;     // 20
const endIndex = startIndex + itemsPerPage;              // 30

The tricky part isn't the math. It's handling edge cases like empty datasets, single pages, and boundary conditions that'll make your component break in weird ways.

💡 Tip: Always test with edge cases like 0 items, 1 item, and datasets that don't divide evenly by itemsPerPage.

Building the Core Component

Step 1: The Foundation

Here's our starting point. Notice how we're thinking about the props upfront:

import React from 'react';
import './Pagination.css';

function Pagination({ 
  totalItems, 
  itemsPerPage = 10, 
  currentPage = 1, 
  onPageChange,
  maxVisiblePages = 5 
}) {
  // Bail early if there's nothing to paginate
  if (totalItems <= itemsPerPage) return null;

  const totalPages = Math.ceil(totalItems / itemsPerPage);
  
  // Prevent invalid page states
  const safePage = Math.max(1, Math.min(currentPage, totalPages));
  
  const handlePageChange = (page) => {
    if (page >= 1 && page <= totalPages && page !== currentPage) {
      onPageChange(page);
    }
  };

  return (
    <nav className="pagination" role="navigation" aria-label="Pagination">
      <button 
        onClick={() => handlePageChange(safePage - 1)}
        disabled={safePage === 1}
        className="pagination__btn pagination__btn--prev"
        aria-label="Go to previous page"
      >
        Previous
      </button>
      
      <div className="pagination__pages">
        {renderPageButtons(safePage, totalPages, maxVisiblePages, handlePageChange)}
      </div>
      
      <button 
        onClick={() => handlePageChange(safePage + 1)}
        disabled={safePage === totalPages}
        className="pagination__btn pagination__btn--next"
        aria-label="Go to next page"
      >
        Next
      </button>
    </nav>
  );
}

export default Pagination;

Step 2: Smart Page Button Rendering

The real challenge is showing the right page numbers without cluttering the UI. Here's how to handle it:

function renderPageButtons(currentPage, totalPages, maxVisible, onPageChange) {
  const pages = [];
  let startPage, endPage;

  if (totalPages <= maxVisible) {
    // Show all pages if we have few enough
    startPage = 1;
    endPage = totalPages;
  } else {
    // Calculate the range around current page
    const halfVisible = Math.floor(maxVisible / 2);
    
    if (currentPage <= halfVisible) {
      startPage = 1;
      endPage = maxVisible;
    } else if (currentPage + halfVisible >= totalPages) {
      startPage = totalPages - maxVisible + 1;
      endPage = totalPages;
    } else {
      startPage = currentPage - halfVisible;
      endPage = currentPage + halfVisible;
    }
  }

  // Add first page + ellipsis if needed
  if (startPage > 1) {
    pages.push(
      <button key={1} onClick={() => onPageChange(1)} className="pagination__page">
        1
      </button>
    );
    
    if (startPage > 2) {
      pages.push(
        <span key="start-ellipsis" className="pagination__ellipsis">
          ...
        </span>
      );
    }
  }

  // Add the visible page range
  for (let i = startPage; i <= endPage; i++) {
    pages.push(
      <button
        key={i}
        onClick={() => onPageChange(i)}
        className={`pagination__page ${currentPage === i ? 'pagination__page--active' : ''}`}
        aria-label={`Go to page ${i}`}
        aria-current={currentPage === i ? 'page' : undefined}
      >
        {i}
      </button>
    );
  }

  // Add ellipsis + last page if needed
  if (endPage < totalPages) {
    if (endPage < totalPages - 1) {
      pages.push(
        <span key="end-ellipsis" className="pagination__ellipsis">
          ...
        </span>
      );
    }
    
    pages.push(
      <button 
        key={totalPages} 
        onClick={() => onPageChange(totalPages)}
        className="pagination__page"
      >
        {totalPages}
      </button>
    );
  }

  return pages;
}

⚠️ Warning: Be careful with the ellipsis logic. Off-by-one errors here create confusing UX where ellipsis appear when they shouldn't.

Step 3: Styling That Actually Works

CSS that focuses on usability over flashiness:

.pagination {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  margin: 24px 0;
  font-family: inherit;
}

.pagination__btn,
.pagination__page {
  padding: 8px 12px;
  border: 1px solid #d1d5db;
  background: white;
  color: #374151;
  font-size: 14px;
  cursor: pointer;
  border-radius: 6px;
  transition: all 0.2s ease;
  min-width: 40px;
}

.pagination__btn:hover:not(:disabled),
.pagination__page:hover {
  background: #f3f4f6;
  border-color: #9ca3af;
}

.pagination__btn:disabled {
  background: #f9fafb;
  color: #9ca3af;
  cursor: not-allowed;
  border-color: #e5e7eb;
}

.pagination__page--active {
  background: #3b82f6;
  color: white;
  border-color: #3b82f6;
}

.pagination__page--active:hover {
  background: #2563eb;
  border-color: #2563eb;
}

.pagination__pages {
  display: flex;
  align-items: center;
  gap: 4px;
}

.pagination__ellipsis {
  color: #6b7280;
  padding: 8px 4px;
  user-select: none;
}

/* Responsive adjustments */
@media (max-width: 640px) {
  .pagination {
    gap: 4px;
  }
  
  .pagination__btn,
  .pagination__page {
    padding: 6px 8px;
    font-size: 13px;
    min-width: 32px;
  }
}

Putting It All Together

Here's how you'd use this in a real component. Notice the data fetching pattern:

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

function ProductList() {
  const [products, setProducts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [currentPage, setCurrentPage] = useState(1);
  const [totalItems, setTotalItems] = useState(0);
  
  const itemsPerPage = 12;

  useEffect(() => {
    fetchProducts(currentPage);
  }, [currentPage]);

  const fetchProducts = async (page) => {
    setLoading(true);
    try {
      // In a real app, this would be an API call
      const response = await fetch(`/api/products?page=${page}&limit=${itemsPerPage}`);
      const data = await response.json();
      
      setProducts(data.products);
      setTotalItems(data.totalCount);
    } catch (error) {
      console.error('Failed to fetch products:', error);
    } finally {
      setLoading(false);
    }
  };

  const handlePageChange = (page) => {
    setCurrentPage(page);
    // Scroll to top when page changes
    window.scrollTo({ top: 0, behavior: 'smooth' });
  };

  if (loading) return <div className="loading">Loading products...</div>;

  return (
    <div className="product-list">
      <div className="product-grid">
        {products.map(product => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
      
      <Pagination
        totalItems={totalItems}
        itemsPerPage={itemsPerPage}
        currentPage={currentPage}
        onPageChange={handlePageChange}
        maxVisiblePages={5}
      />
    </div>
  );
}

📌 Note: This example shows server-side pagination, which is usually what you want for large datasets. Client-side pagination works fine for smaller datasets (under 1000 items).

Advanced Features Worth Adding

URL Integration

Users expect pagination state to survive page refreshes. Here's how to sync with the URL:

import { useSearchParams } from 'react-router-dom';

function usePageFromURL() {
  const [searchParams, setSearchParams] = useSearchParams();
  
  const currentPage = parseInt(searchParams.get('page')) || 1;
  
  const setPage = (page) => {
    setSearchParams(prev => {
      prev.set('page', page.toString());
      return prev;
    });
  };

  return [currentPage, setPage];
}

Items Per Page Selector

Sometimes users want control over page size:

function PageSizeSelector({ pageSize, onPageSizeChange, options = [10, 25, 50, 100] }) {
  return (
    <div className="page-size-selector">
      <label htmlFor="page-size">Items per page:</label>
      <select 
        id="page-size"
        value={pageSize} 
        onChange={(e) => onPageSizeChange(Number(e.target.value))}
      >
        {options.map(size => (
          <option key={size} value={size}>{size}</option>
        ))}
      </select>
    </div>
  );
}

Loading States

Show loading indicators during page transitions:

function Pagination({ loading, ...props }) {
  return (
    <div className={`pagination-wrapper ${loading ? 'pagination-wrapper--loading' : ''}`}>
      {loading && <div className="pagination__loading">Loading...</div>}
      <Pagination {...props} />
    </div>
  );
}

Common Gotchas and Solutions

State Management Issues

Don't reset pagination state when filtering data. Users get confused when they're on page 5 and suddenly jump to page 1 after changing a filter.

// Bad: Always reset to page 1
const handleFilterChange = (newFilter) => {
  setFilter(newFilter);
  setCurrentPage(1); // Jarring UX
};

// Better: Reset only if current page would be empty
const handleFilterChange = (newFilter) => {
  setFilter(newFilter);
  
  // Reset to page 1 only if necessary
  if (currentPage > Math.ceil(filteredCount / itemsPerPage)) {
    setCurrentPage(1);
  }
};

Accessibility Oversights

Screen readers need proper markup. Always include:

  • role="navigation" and aria-label on the pagination container
  • aria-current="page" on the active page button
  • Descriptive aria-label attributes on navigation buttons

Performance Problems

Don't recalculate page arrays on every render:

import { useMemo } from 'react';

function Pagination({ currentPage, totalPages, maxVisible, onPageChange }) {
  const pageButtons = useMemo(() => 
    generatePageButtons(currentPage, totalPages, maxVisible),
    [currentPage, totalPages, maxVisible]
  );

  return (
    <div className="pagination">
      {pageButtons.map(button => renderButton(button, onPageChange))}
    </div>
  );
}

Testing Your Component

Essential test cases to cover:

describe('Pagination Component', () => {
  it('handles single page datasets gracefully', () => {
    render(<Pagination totalItems={5} itemsPerPage={10} />);
    expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
  });

  it('disables previous button on first page', () => {
    render(<Pagination totalItems={100} currentPage={1} />);
    expect(screen.getByLabelText(/previous/i)).toBeDisabled();
  });

  it('shows ellipsis for large page ranges', () => {
    render(<Pagination totalItems={1000} currentPage={50} maxVisiblePages={5} />);
    expect(screen.getAllByText('...')).toHaveLength(2);
  });
});

Wrapping Up

Building custom pagination taught me more about React patterns than I expected. You'll handle edge cases, think about accessibility, and create something that actually improves user experience instead of just looking pretty.

The component we built handles the real-world scenarios you'll encounter: large datasets, responsive design, accessibility requirements, and integration with modern React patterns like URL state management.

Start with this foundation and extend it based on your app's needs. You might add infinite scroll for mobile, keyboard navigation, or custom styling hooks. The point is you now have complete control over how pagination works in your application.

Topics Covered

About Author

I'm Maulik Paghdal, the founder of Script Binary and a passionate full-stack web developer. I have a strong foundation in both frontend and backend development, specializing in building dynamic, responsive web applications using Laravel, Vue.js, and React.js. With expertise in Tailwind CSS and Bootstrap, I focus on creating clean, efficient, and scalable solutions that enhance user experiences and optimize performance.