machine coding11
react ui components
render-propsuseMemocomponent-api

Pagination

Build a reusable pagination component for a large list.

medium1 min readsolution

What it tests

Component API design. Slicing an array by page is trivial; the actual question is how the component stays reusable when the next screen needs different markup for both the rows and the controls.

The approach

Headless, via render props. Pagination owns the page state and the maths and renders nothing opinionated — the caller supplies renderItem and renderPagination, and receives the state and movers as arguments:

renderPagination({
  page,
  totalPages,
  goTo,
  next: () => goTo(page + 1),
  prev: () => goTo(page - 1),
});

goTo clamps to the valid range in one place, so next, prev and a direct page click can't each invent their own bounds check. The slice is memoised on [data, page, pageSize].

Where it usually goes wrong

  • Baking in the button markup. Works once, gets copy-pasted the second time.
  • Bounds checks at every call site. Guard inside goTo instead.
  • Not resetting on data change. If data shrinks while you're on page 20, page is now out of range and the list renders empty.
  • Rendering every page number. 2,000 items at 10 a page is 200 buttons; real pagination windows them around the current page.

Full solution

2 files from src/folders/Pagination.

index.jsx

import { Pagination } from "./pagination";
 
const Data = [...new Array(2000)].map((_, i) => i + 1);
 
export default function App() {
  return (
    <Pagination
      data={Data}
      pageSize={10}
      renderItem={(item) => (
        <li key={item} className="list-none">
          {item}
        </li>
      )}
      renderPagination={({ page, totalPages, goTo, next, prev }) => (
        <div className="mt-4 flex gap-2">
          <button onClick={prev} disabled={page === 1}>
            Prev
          </button>
 
          {[...new Array(Math.min(totalPages, 10))].map((_, idx) => {
            const p = idx + 1;
            return (
              <button
                key={p}
                onClick={() => goTo(p)}
                style={{
                  background: page === p ? "red" : "white",
                  border: "1px solid black",
                  padding: "4px 8px",
                }}
              >
                {p}
              </button>
            );
          })}
 
          <button onClick={next} disabled={page === totalPages}>
            Next
          </button>
        </div>
      )}
    />
  );
}

pagination.jsx

import { useMemo, useState } from "react";
 
export function Pagination({
  data = [],
  pageSize = 10,
  initialPage = 1,
  renderItem,
  renderPagination,
}) {
  const [page, setPage] = useState(initialPage);
 
  const totalPages = Math.ceil(data.length / pageSize);
 
  const pageData = useMemo(() => {
    const start = (page - 1) * pageSize;
    return data.slice(start, start + pageSize);
  }, [data, page, pageSize]);
 
  function goTo(p) {
    if (p < 1 || p > totalPages) return;
    setPage(p);
  }
 
  return (
    <div>
      <div>{pageData.map((item, idx) => renderItem(item, idx))}</div>
 
      {renderPagination({
        page,
        totalPages,
        goTo,
        next: () => goTo(page + 1),
        prev: () => goTo(page - 1),
      })}
    </div>
  );
}
share this post