- Published on
Building a Reusable Pagination Component in React From Scratch
- Authors
- Name
- Md Nasim Sheikh
- @nasimStg
'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
- 'Building a Reusable Pagination Component in React From Scratch'
- Why Bother with Pagination?
- Section 1: The Core Logic - It's All About the Math
- Section 2: Building the Component Scaffolding
- Section 3: Integrating the Pagination Component
- Section 4: Advanced Features & Best Practices
- 1. Handling Large Page Ranges with Ellipses (...)
- 2. Enhancing Accessibility (a11y)
- 3. Styling Your Component
- Conclusion
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.
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.
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.
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 functiononPageChange
. 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 returnnull
. - Page Numbers Array: A simple
for
loop generates an array of numbers from 1 tototalPages
. - Rendering: We
map
over thepageNumbers
array to render a button for each page. Theactive
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 thedisabled
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 anaria-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:
- State Management: We use
useState
to keep track of thecurrentPage
. This is the single source of truth for our pagination state. - Data Slicing: This is where the magic happens. We calculate the
firstPageIndex
andlastPageIndex
based on thecurrentPage
andITEMS_PER_PAGE
. Then, we use theArray.prototype.slice()
method to extract just the items for the current page. We wrap this logic inuseMemo
as a performance optimization, ensuring the slicing only re-runs whencurrentPage
changes. handlePageChange
: This function is passed down to ourPagination
component as theonPageChange
prop. When a user clicks a page button, ourPagination
component calls this function, which then updates thecurrentPage
state inItemsList
. This state update triggers a re-render, and ouruseMemo
hook provides the newcurrentItems
for display.- Rendering: We render the list of
currentItems
and, right below it, ourPagination
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">…</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 thedisabled
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!