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 incrementally: a working Previous/Next first, then URL sync so deep links work, then a page-jump component for long lists. Every step uses theX-Pagination-* headers Go REST returns.
The headers you need
Every list response from Go REST includes:
X-Pagination-Totalis the total number of rows matching the filter.X-Pagination-Pagesis the total number of pages.X-Pagination-Pageis the page you got back.X-Pagination-Limitis the page size in rows (default 10, override with?per_page=Nup to 100).
ReadX-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 re-fetches.
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:
disabledon Previous whenpage <= 1and on Next whenpage >= total. Cheaper than guessing.busyflag prevents double-clicks during fetch. Skip this, and impatient users fire off two requests for the same page.- The fetch reads code 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 onpage derived fromparams. Hitting "Next" updates the URL, which updatespage, which triggers the effect, which fetches the next page. URL is the single source of truth.
Page-jump component
For lists with more than ~10 pages, Previous/Next is not enough; users want to jump to page 47 or skim 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);
}
ThepageWindow helper centres a 5-page window around the current page, clamped to the valid range. Page 1 shows, page 50 of 100 shows, 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 polish: when the user is on page 3, optimistically fetch page 4 in the background so clicking Next feels instant. Use a separateuseEffect that fires whenpage 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 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. Lose URL sync, and your page does not survive a refresh. Always use URL params for shareable state.
- Not resetting on filter change. Filter narrows results from 1000 to 30, user is on page 47, page is now empty: confusing.
Keep building
Test loading skeletons with simulated latency
Use ?delay=N to make Go REST take seconds to respond, so you can verify your skeleton screens render without flicker.
Test 401, 422, 429 and 500 handlers
Force any status code with ?force_status= to make sure your error UI never shows blank screens or eats stack traces.
Build infinite scroll with IntersectionObserver
Append pages as the sentinel element scrolls into view. Works with any list endpoint that returns pagination headers.