Pagination

ReactMedium30 minRazorpayStripe

Prompt

Dashboards at Razorpay, Stripe, and most admin tools need to show long lists of rows without dumping them all onto the page at once. The standard control is a page-size selector with a pair of Prev and Next buttons.

You are given a fixed list of 40 anime records. Build a table that shows only one page at a time, along with a control row that lets the user change the page size and move between pages.

The challenge looks small, but there are a few decisions that interviewers listen for: what belongs in state versus what can be derived, how to keep the page valid when the page size changes, and how to wire a semantic <table> with accessible controls.

Requirements

  • The table shows only the current page of rows at any time
  • A dropdown lets the user pick a page size of 5, 10, or 20
  • Pressing Prev moves to the previous page; pressing Next moves to the next page
  • Prev is disabled on the first page; Next is disabled on the last page
  • The controls show the current page and the total number of pages
  • Changing the page size should never leave the user on a page that no longer exists

Example

Pagination

Playground

Hint 1

You only need two pieces of state: the current page and the page size. Everything else, including the total number of pages and the rows to display, can be derived on each render.

const [currentPage, setCurrentPage] = React.useState(1);
const [pageSize, setPageSize] = React.useState(5);
const totalPages = Math.ceil(items.length / pageSize);
Hint 2

Slice the items array to get the rows for the current page. The start index is (currentPage - 1) * pageSize.

const startIndex = (currentPage - 1) * pageSize;
const visibleRows = items.slice(
startIndex,
startIndex + pageSize
);
Hint 3

When the page size changes, the old currentPage might not exist anymore. Reset currentPage to 1 whenever the page size changes.

const handlePageSizeChange = (size) => {
setPageSize(size);
setCurrentPage(1);
};

Solution

Explanation

I will walk through the solution in the order I would write it in an interview: state first, then the table, then the pagination control, and finally the page-size edge case.

Picking the State

The first question: what needs to be in state, and what can be computed from that state?

We need to know which page the user is currently on and how many rows to show per page. Those two values cannot be derived from anything else, so they go in state.

const [currentPage, setCurrentPage] = React.useState(1);
const [pageSize, setPageSize] = React.useState(5);

Everything else follows from these two values plus the anime array:

  • totalPages is Math.ceil(anime.length / pageSize)
  • The start index is (currentPage - 1) * pageSize
  • The rows to render are anime.slice(startIndex, startIndex + pageSize)

There is no reason to store totalPages or visibleRows in state. Both can be computed from the state we already have. Storing derived values is a common interview mistake. It creates two sources of truth, so bugs appear when one value updates and the other does not.

State vs Derived

A simple rule: if you can compute a value from existing state or props, do not store it. Store only what cannot be computed.

Many candidates add a third useState for totalPages and then use a useEffect to keep it updated when pageSize changes. This is extra code for no reason. totalPages comes from items.length and pageSize. Both are there on every render. Just compute it: Math.ceil(items.length / pageSize). No extra state, no effect, no useMemo needed.

Slicing the Page

With the state set up, getting the rows for the current page takes two lines:

App.js

const startIndex = (currentPage - 1) * pageSize;
const visibleRows = anime.slice(
startIndex,
startIndex + pageSize
);

Two points worth saying out loud:

  1. slice returns a new array and does not change the original. Even if the interviewer does not ask, it is a good habit to mention.
  2. We multiply by pageSize, not by a hardcoded 5. The math has to use the state, otherwise the dropdown will not change anything.

The Table

The starter already has a DataTable component. It uses a real <table> element rather than divs with display: grid. Divs can look the same, but screen readers will not announce the rows and columns, keyboard navigation will not work the same way, and the browser cannot size columns for you.

components/DataTable.js

export default function DataTable({ rows }) {
return (
<table className="table">
<thead>
<tr>
<th>ID</th>
<th>Title</th>
<th>Year</th>
<th>Studio</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<td>{row.id}</td>
<td>{row.title}</td>
<td>{row.year}</td>
<td>{row.studio}</td>
</tr>
))}
</tbody>
</table>
);
}

DataTable only renders rows. It does not know about pagination. The parent (App) holds the state and slices the array, then passes the sliced array to DataTable through the rows prop. On every page change the parent computes a new slice, and DataTable re-renders with it.

Building the Pagination Control

The starter Pagination component is empty. You build the whole control here. It has three jobs: change the page size, move between pages, and show the current page.

components/Pagination.js

export default function Pagination({
currentPage,
totalPages,
pageSize,
pageSizeOptions,
onPageChange,
onPageSizeChange,
}) {
const isFirstPage = currentPage === 1;
const isLastPage = currentPage === totalPages;
return (
<div className="controls">
<select
aria-label="Rows per page"
value={pageSize}
onChange={(event) =>
onPageSizeChange(Number(event.target.value))
}
>
{pageSizeOptions.map((size) => (
<option key={size} value={size}>
Show {size}
</option>
))}
</select>
<button
type="button"
aria-label="Previous page"
disabled={isFirstPage}
onClick={() => onPageChange(currentPage - 1)}
>
Prev
</button>
<span className="page-label">
Page {currentPage} of {totalPages}
</span>
<button
type="button"
aria-label="Next page"
disabled={isLastPage}
onClick={() => onPageChange(currentPage + 1)}
>
Next
</button>
</div>
);
}

A few details to call out:

  1. The <select> is controlled: its value is pageSize and its onChange sends the new value to the parent.
  2. Number(event.target.value) matters. Form values come in as strings. If you store the string, math like items.length / pageSize still works but a strict check like pageSize === 10 will not. Convert to a number at this boundary.
  3. Prev and Next use the native disabled attribute. A disabled button does not fire onClick and cannot be activated by keyboard, so you do not need extra guards.
  4. Each control has an aria-label. A button with only an icon or a select with no label is invisible to screen readers. "Rows per page", "Previous page", and "Next page" read clearly.

Wiring the App Together

App.js

const PAGE_SIZE_OPTIONS = [5, 10, 20];
export default function App() {
const [currentPage, setCurrentPage] = React.useState(1);
const [pageSize, setPageSize] = React.useState(5);
const totalPages = Math.ceil(anime.length / pageSize);
const startIndex = (currentPage - 1) * pageSize;
const visibleRows = anime.slice(
startIndex,
startIndex + pageSize
);
const handlePageSizeChange = (size) => {
setPageSize(size);
setCurrentPage(1);
};
return (
<main className="wrapper">
<DataTable rows={visibleRows} />
<Pagination
currentPage={currentPage}
totalPages={totalPages}
pageSize={pageSize}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageChange={setCurrentPage}
onPageSizeChange={handlePageSizeChange}
/>
</main>
);
}

PAGE_SIZE_OPTIONS is declared outside the component so it is not created again on every render. Values that never change do not need to be inside the function body.

The Page-Size Edge Case

This is the case most candidates miss. Say the user is on page 6 of 8 with pageSize set to 5. They change pageSize to 20. The new totalPages is 2. The user is now on page 6, which does not exist.

There are two reasonable fixes:

  1. Reset currentPage to 1 when pageSize changes.
  2. Clamp currentPage to the new totalPages if it is out of range.

Option 1 is simpler and matches what most real products do. Changing how many rows you see usually means you want to start from the top. That is what the solution does:

const handlePageSizeChange = (size) => {
setPageSize(size);
setCurrentPage(1);
};

Common Pitfalls

Do not try to fix this with an effect:

React.useEffect(() => {
if (currentPage > totalPages) setCurrentPage(totalPages);
}, [totalPages]);

This works, but it runs an extra render. The event handler already knows the page size is changing, so it can reset currentPage in the same update. Use an effect only when a change comes from outside, like a prop or a fetch. Do not use one to react to your own state update.

Styling

The styling is minimal on purpose. Native <select> and <button> elements already look fine and are keyboard and screen-reader accessible by default. No time spent on custom styles means more time for the logic.

.table {
border-collapse: collapse;
width: 100%;
font-size: 14px;
}
.table th,
.table td {
padding: 8px 16px;
text-align: left;
border-bottom: 1px solid var(--gray-6);
}
.table th {
font-weight: 700;
}
.controls {
display: flex;
align-items: center;
gap: 12px;
font-size: 14px;
}
.page-label {
color: var(--gray-11);
}

Key decisions:

  • border-collapse: collapse makes the <th> and <tr> borders share an edge, so there is no double line between rows.
  • Row separators are a single border-bottom on each <th> and <tr>. No outer table border, no zebra striping, no shadows.
  • The only colors used are four gray tokens. No brand color is needed for this component.

What a Senior Signal Looks Like

If you have time left, mention one or two of these out loud. You do not have to implement them; naming them is the signal:

  1. URL state. Right now the page resets on refresh. Syncing currentPage and pageSize to the URL (via useSearchParams in Next.js or nuqs) makes the UI linkable and shareable.
  2. Server pagination. For 40 rows in memory, client slicing is fine. For 40,000 rows, you would fetch by page and keep a loading state per request. The component API does not have to change; only App.js does.
  3. Virtualization for very large pages. If a single page still holds thousands of rows (for example, pageSize of 5,000), rendering every <tr> slows the browser. A library like react-window or @tanstack/react-virtual renders only the rows in view and reuses their DOM nodes as the user scrolls. This pairs well with pagination: pagination cuts the dataset into pages, virtualization keeps each page cheap to render.
  4. Keyboard shortcuts. ArrowLeft and ArrowRight to move between pages is a power-user feature used by Gmail and Notion.

Interviewer Criteria

HTML/CSS

  • Did I use a semantic <table> with <thead> and <tbody> instead of a grid of divs?

  • Are my row separators a single border-bottom rather than top and bottom borders that stack?

  • Did I leave <select> and <button> with their native browser styling instead of over-designing them?

JavaScript

  • Did I coerce the <select> value to a Number before storing it in state?

  • Did I slice the items array with (currentPage - 1) * pageSize as the start index?

  • Did I avoid mutating the items array?

React

  • Did I keep only currentPage and pageSize in state, and derive totalPages and the visible rows on each render?

  • Did I reset currentPage when the page size changes so the user is never stranded on a page that no longer exists?

  • Did I keep DataTable presentational and let the parent own the pagination state?

Accessibility

  • Do Prev and Next have aria-label values that read naturally when announced?

  • Does the page-size select have a label associated with it (via <label> or aria-label)?

  • Do Prev and Next use the native disabled attribute so they are skipped by keyboard navigation when unavailable?

Time Checkpoints

  • 10:00 AM

    Interview starts 👥

  • 10:03 AM

    Prompt clarified and data reviewed

  • 10:08 AM

    State for currentPage and pageSize wired up

  • 10:14 AM

    Table renders the sliced rows for the current page

  • 10:22 AM

    Pagination controls working with Prev/Next disabled states

  • 10:27 AM

    Page-size reset edge case handled and aria-labels added

  • 10:30 AM

    Discussion with interviewer