Skip to content

Build a paginated user list in React

A working component that fetches a page, renders rows, and uses X-Pagination-Pages to render Previous/Next correctly.

Pagination on a list endpoint is one of the most common patterns in any UI, and also one of the most common places to get details wrong. This recipe builds it step by step: a working Previous/Next first, then URL sync so deep links work, then a page-jump component for long lists. Every step uses the X-Pagination-* headers GoRest returns.

The headers you need

Every list response from GoRest includes:

  • X-Pagination-Total is the total number of rows matching the filter.
  • X-Pagination-Pages is the total number of pages.
  • X-Pagination-Page is the page you got back.
  • X-Pagination-Limit is the page size in rows (default 10, override with ?per_page=N up to 100).

Read X-Pagination-Pages on the response and use it to disable Next when you are on the last page. Without it, you have to guess, and guessing wrong means a "Next" button that returns an empty list.

The minimal component

One state for the current page, one for the rows, one for the total. Every page change fetches again.

import { useState, useEffect } from "react";

function UserList() {
  const [page, setPage]   = useState(1);
  const [users, setUsers] = useState([]);
  const [total, setTotal] = useState(1);
  const [busy, setBusy]   = useState(false);

  useEffect(() => {
    setBusy(true);
    fetch(`https://gorest.co.in/public/v2/users?page=${page}`)
      .then(async (res) => {
        setTotal(parseInt(res.headers.get("x-pagination-pages") || "1", 10));
        return res.json();
      })
      .then(setUsers)
      .finally(() => setBusy(false));
  }, [page]);

  return (
    <div>
      <table>
        <thead>
          <tr><th>Name</th><th>Email</th><th>Status</th></tr>
        </thead>
        <tbody>
          {users.map(u => (
            <tr key={u.id}>
              <td>{u.name}</td>
              <td>{u.email}</td>
              <td>{u.status}</td>
            </tr>
          ))}
        </tbody>
      </table>

      <nav className="pagination">
        <button onClick={() => setPage(p => p - 1)} disabled={page <= 1 || busy}>
          Previous
        </button>
        <span>Page {page} of {total}</span>
        <button onClick={() => setPage(p => p + 1)} disabled={page >= total || busy}>
          Next
        </button>
      </nav>
    </div>
  );
}

A few details worth pointing out:

  • disabled on Previous when page <= 1 and on Next when page >= total. Cheaper than guessing.
  • The busy flag prevents double-clicks during fetch. Skip this, and impatient users fire off two requests for the same page.
  • The fetch reads x-pagination-pages from the response headers in lowercase, because the Headers API normalises to lowercase regardless of how the server sent them.

Sync to the URL

Pagination state belongs in the URL so users can copy a link, refresh, or hit the back button to return to a specific page. With React Router:

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

function UserList() {
  const [params, setParams] = useSearchParams();
  const page = parseInt(params.get("page") || "1", 10);

  function setPage(n) {
    setParams({ page: String(n) });
  }
  // ...same fetch as before, keyed on `page`
}

The fetch effect now keys on page derived from params. Hitting "Next" updates the URL, which updates page, which triggers the effect, which fetches the next page. The URL is the single source of truth.

Page-jump component

For lists with more than about 10 pages, Previous/Next is not enough; users want to jump to page 47 or see the page count. A page-jump component renders a sliding window around the current page.

function PageJump({ page, total, setPage }) {
  const visible = pageWindow(page, total, 5); // [1, 2, 3, 4, 5]

  return (
    <ul className="pagination">
      {visible.map(n => (
        <li key={n}>
          <button
            aria-current={n === page ? "page" : undefined}
            onClick={() => setPage(n)}
            disabled={n === page}>
            {n}
          </button>
        </li>
      ))}
    </ul>
  );
}

function pageWindow(current, total, size) {
  const half = Math.floor(size / 2);
  let start = Math.max(1, current - half);
  let end   = Math.min(total, start + size - 1);
  start = Math.max(1, end - size + 1);
  return Array.from({ length: end - start + 1 }, (_, i) => start + i);
}

The pageWindow helper centres a 5-page window around the current page, clamped to the valid range. Page 1 shows [1, 2, 3, 4, 5], page 50 of 100 shows [48, 49, 50, 51, 52], and the last page shows the last 5.

Filtering and pagination together

When the user changes a filter (status, search term), reset to page 1; otherwise they end up on page 47 of a filtered list that only has 3 pages. Make filter changes go through a single state setter that resets page:

function setFilter(next) { setStatus(next); setPage(1); }

Pre-fetch the next page

A nice extra: when the user is on page 3, fetch page 4 in the background so clicking Next feels instant. Use a separate useEffect that fires when page is stable; cache the result keyed on page number.

Common mistakes

  • Calculating total client-side. Some devs read the array length on each page and try to derive the total; it cannot work, since you only have one page's worth. Always read X-Pagination-Pages.
  • Forgetting the busy state. Without it, the user can spam Next, and your component races on which response arrives first.
  • Storing pagination in state alone. Without URL sync, your page does not survive a refresh. Always use URL params for shareable state.
  • Not resetting on filter change. The filter narrows results from 1000 to 30, the user is on page 47, and the page is now empty: confusing.
More recipes

Keep building