How to Build a Pagination Component in React
Advertisement
🧩 Scenario
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
| Approach | When to use | Data requirement |
|---|---|---|
| Client-side slicing | Small datasets already in memory | All items loaded upfront |
| Server-side pagination | Large datasets; unknown total count | API accepts page + limit params |
| Cursor-based pagination | Infinite scroll; real-time data | API 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.ceilfor 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
- How would you add keyboard navigation so that left/right arrow keys move between pages?
- How would you truncate page number buttons with ellipsis when there are more than 7 pages (e.g.,
1 2 3 ... 18 19 20)? - How would you sync the current page to the URL query parameter so the user can share a link to a specific page?
- Describe the UX and implementation difference between traditional pagination and infinite scroll. When is each appropriate?
- How does React Query handle server-side pagination with
keepPreviousDataand why is it important for UX?
Common Candidate Mistakes
- Storing
visibleItemsin state and syncing withuseEffect— creates a one-render delay and stale data bugs - Using
Math.floorinstead ofMath.ceilfortotalPages— drops the last partial page - Forgetting to disable navigation buttons at the boundaries — allows navigating to page 0 or beyond
- Not resetting
currentPagewhen 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, andvisibleItemsfromcurrentPageandpageSizewithout using state? - Can you compute
totalPageswithMath.ceiland 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.
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