Skip to content

React Tutorial Data and APIs

Pagination in React: Page Numbers, Page Size and Server Paging

Lesson 97 of 156 2 min read By Deepak Paswan Updated Oct 10, 2026

Runnable lesson · press Try it on the examples to edit and run them here.

Showing thousands of items on one page is slow and hard to use. Pagination splits a list into pages. The pattern needs only a few pieces of state and a little arithmetic.

Client-side pagination

When the whole list is already in memory, slice the page you need:

App.jsx
JSX
const ITEMS = Array.from({ length: 20 }, (_, i) => "Item " + (i + 1));
const PAGE_SIZE = 5;

function App() {
  const [page, setPage] = React.useState(1);

  const totalPages = Math.ceil(ITEMS.length / PAGE_SIZE);   // derived
  const start = (page - 1) * PAGE_SIZE;
  const pageItems = ITEMS.slice(start, start + PAGE_SIZE);  // derived

  return (
    <div>
      <ul>{pageItems.map((item) => <li key={item}>{item}</li>)}</ul>
      <nav aria-label="Pagination">
        <button onClick={() => setPage(1)} disabled={page === 1}>First</button>{" "}
        <button onClick={() => setPage(page - 1)} disabled={page === 1}>Previous</button>{" "}
        <span>Page {page} of {totalPages}</span>{" "}
        <button onClick={() => setPage(page + 1)} disabled={page === totalPages}>Next</button>{" "}
        <button onClick={() => setPage(totalPages)} disabled={page === totalPages}>Last</button>
      </nav>
    </div>
  );
}

Only page is state. totalPages and pageItems are calculated. The maths:

  • totalPages = Math.ceil(totalItems / pageSize)
  • first index of a page = (page - 1) * pageSize
  • slice(start, start + pageSize) gives that page’s items.

Disable Previous on the first page and Next on the last.

Reset the page when the list changes

If a search or filter changes the number of items, the current page may no longer exist. Reset to page 1 when the filter changes (in the event handler):

App.jsx
JSX
const ITEMS = Array.from({ length: 12 }, (_, i) => "Item " + (i + 1));

function App() {
  const [page, setPage] = React.useState(1);
  const [filter, setFilter] = React.useState("");

  const filtered = ITEMS.filter((i) => i.toLowerCase().includes(filter.toLowerCase()));
  const totalPages = Math.max(1, Math.ceil(filtered.length / 10));
  const current = Math.min(page, totalPages);           // guard against an out-of-range page
  const pageItems = filtered.slice((current - 1) * 10, current * 10);

  return (
    <div>
      <input aria-label="Filter" value={filter} onChange={(e) => { setFilter(e.target.value); setPage(1); }} />
      <ul>{pageItems.map((i) => <li key={i}>{i}</li>)}</ul>
      <button onClick={() => setPage(current - 1)} disabled={current === 1}>Previous</button>
      <span> Page {current} of {totalPages} </span>
      <button onClick={() => setPage(current + 1)} disabled={current === totalPages}>Next</button>
    </div>
  );
}

Server-side pagination

For large data sets the server returns one page at a time. The request includes the page number and size, and the response includes the items and the total:

code
PLAIN
GET /api/products?page=2&pageSize=10
{ "items": [ ... 10 products ... ], "total": 137 }
App.jsx
JSX
// Simulated server with 35 products
function fetchPage(page, pageSize) {
  return new Promise((resolve) =>
    setTimeout(() => {
      const all = Array.from({ length: 35 }, (_, i) => "Product " + (i + 1));
      resolve({ items: all.slice((page - 1) * pageSize, page * pageSize), total: all.length });
    }, 400)
  );
}

function App() {
  const [page, setPage] = React.useState(1);
  const [data, setData] = React.useState({ items: [], total: 0 });
  const [loading, setLoading] = React.useState(true);
  const pageSize = 10;

  React.useEffect(() => {
    let ignore = false;
    setLoading(true);
    fetchPage(page, pageSize).then((result) => {
      if (ignore) return;
      setData(result);
      setLoading(false);
    });
    return () => { ignore = true; };
  }, [page]);

  const totalPages = Math.ceil(data.total / pageSize) || 1;

  return (
    <div aria-busy={loading}>
      <p>Page {page}{loading ? " (loading...)" : ""}</p>
      <ul>{data.items.map((p) => <li key={p}>{p}</li>)}</ul>
      <button onClick={() => setPage(page - 1)} disabled={page === 1 || loading}>Previous</button>{" "}
      <button onClick={() => setPage(page + 1)} disabled={page >= totalPages || loading}>Next</button>
    </div>
  );
}

Each page change runs the effect again, with stale-response protection. While a new page loads, the old items stay visible.

Cursor pagination

Some APIs return a cursor or “next” token instead of page numbers (common for feeds that change often). You keep the cursor from the last response and send it to get the next batch. Page numbers are not available, so the UI is usually Load more or infinite scroll.

Page numbers, Load more or infinite scroll?

PatternGood forWatch out for
Numbered pagesCatalogs and tables where users jump around; shareable URLsMore clicks
Previous / NextSimple listsNo quick jump
Load more buttonFeeds and galleriesFooter unreachable if the list never ends
Infinite scrollSocial feedsHard for keyboard and screen reader users, loses position, footers; always offer an alternative

Accessibility and the URL

  • Wrap controls in <nav aria-label="Pagination"> and mark the current page with aria-current="page" when you render page numbers.
  • After changing the page, tell users: move focus to the list heading, or announce “Page 2 of 4” in a role="status" region.
  • Keep the page number in the URL (?page=2), so Back, refresh and sharing work. See Route Parameters.

Key points

  • Derive totalPages and the visible slice from page, the page size and the list.
  • Reset (or clamp) the page when filters change.
  • For large data, request one page at a time from the server and protect against stale responses.