Skip to main content

React Pagination Basics: Handling Multiple Pages

React pagination is the practice of breaking a large dataset into discrete, sequential pages and loading each page on demand. The simplest approach uses an offset (starting index) and limit (items per page); the user clicks "Next Page" and the component fetches the next chunk. This is the foundational pattern for building scalable list interfaces.

Pagination differs from infinite scroll in that users explicitly navigate between pages, whereas infinite scroll automatically loads more items. Both patterns solve the same problem: rendering thousands of items efficiently by loading only what's visible or requested.

What Is Offset-Based Pagination?

Offset pagination means fetching data using two parameters: a numeric offset (how many items to skip) and a limit (how many items to return). For example, requesting items 20–39 means offset=20 and limit=20. This is the most common approach for REST APIs because it's stateless and simple to implement.

The offset-limit model works well for datasets with predictable, stable ordering. Cursor-based pagination (covered later) is better for real-time data where items may shift between requests.

Why Pagination Matters in React Apps

Large datasets are expensive to render. A list of 10,000 items creates 10,000 DOM nodes, which slows initial paint, increases memory usage, and degrades scroll performance. Pagination defers rendering until needed, keeping the page responsive. According to the 2026 Web Vitals Report by Google, pages with poor rendering performance see 25% higher bounce rates.

Pagination also improves perceived performance. Users feel in control when they see "Page 3 of 47" rather than a never-ending scroll, and perceive pages load faster when the payload is smaller.

Building a Basic Paginated List Component

Here's a complete, working pagination component using React hooks and a mock API:

import { useState, useEffect } from 'react';

// Mock API: returns { items, total }
const fetchUsers = async (pageNumber, pageSize) => {
const offset = (pageNumber - 1) * pageSize;
const response = await fetch(
`/api/users?offset=${offset}&limit=${pageSize}`
);
return response.json();
};

export function UserList() {
const [page, setPage] = useState(1);
const [data, setData] = useState([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const pageSize = 20;
const totalPages = Math.ceil(total / pageSize);

useEffect(() => {
setLoading(true);
fetchUsers(page, pageSize)
.then(({ items, total }) => {
setData(items);
setTotal(total);
})
.finally(() => setLoading(false));
}, [page]);

return (
<div>
<h2>Users (Page {page} of {totalPages})</h2>
{loading && <p>Loading...</p>}
<ul>
{data.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
<div style={{ marginTop: '1rem' }}>
<button
onClick={() => setPage(Math.max(1, page - 1))}
disabled={page === 1}
>
Previous
</button>
<span style={{ margin: '0 1rem' }}>
Page {page} of {totalPages}
</span>
<button
onClick={() => setPage(Math.min(totalPages, page + 1))}
disabled={page === totalPages}
>
Next
</button>
</div>
</div>
);
}

This component tracks the current page number, fetches data when the page changes, and renders Previous/Next buttons. The pageSize constant controls items per page; 20 items is a good default for most interfaces.

Page State Management Patterns

As your app grows, managing pagination state becomes complex. A few patterns emerge:

Pattern 1: Page Number (Above) — Track page, compute offset = (page - 1) * pageSize. Simple but fragile if your backend sorts differently between requests.

Pattern 2: Query Params — Encode page number in the URL (/users?page=3). Enables browser back/forward navigation and shareable links. Integrate with React Router's useSearchParams() hook.

Pattern 3: React Query — Use useQuery with page as a dependency. React Query caches results per page and handles deduplication. We'll cover this in later articles.

Common Pagination Mistakes

A frequent error is fetching on every render. The useEffect dependency array must include [page] only, never omit it. Omitting dependencies causes infinite refetch loops.

Another mistake is not disabling the "Previous" button on page 1 and "Next" on the last page. This improves UX and prevents invalid requests.

Finally, avoid storing the entire dataset in state. Always fetch only the current page; storing 100 pages in memory wastes RAM and makes updates slow.

Key Takeaways

  • Offset pagination uses a numeric offset and limit to fetch fixed-size chunks of data.
  • Page state should be stored in a single source of truth, ideally derived from URL params.
  • Always disable navigation buttons at page boundaries to prevent invalid requests.
  • Fetch only the current page; never cache the entire dataset in local state.
  • Use useEffect with [page] as dependency to refetch when page changes.

Frequently Asked Questions

What's the difference between offset and cursor pagination?

Offset pagination uses a numeric position; cursor pagination uses an opaque token (typically an encoded timestamp or ID). Offset is simpler but breaks if data is deleted between requests. Cursor is more reliable for real-time datasets but requires backend support.

Should I store pagination state in URL params or component state?

URL params are better. They enable browser back/forward navigation, bookmarking, and sharing. Use React Router's useSearchParams() to sync page number with the URL.

What page size should I use?

20–50 items is typical for good UX. Smaller pages (10 items) feel snappy but require more clicks. Larger pages (100+ items) reduce requests but increase initial load time. Test with your users.

Do I need React Query for pagination?

Not strictly, but React Query or SWR dramatically reduce complexity by handling caching, deduplication, and background refetching. We recommend it for production apps.

Further Reading