Build a paginated data table: a fixed list of users shown a page at a time, with Prev/Next buttons, a "Page X of Y" indicator, and a page-size selector. The list itself never changes — what changes is the window of rows you show. The core idea is that the visible rows are a slice of the full array, and the slice bounds come from two numbers: the current page and the page size.
// A self-contained component. No props.
function App(): JSX.Element;
A <table> showing one page of users, plus pagination controls.
10 users, pageSize = 5, page = 0 → rows 1–5, "Page 1 of 2"
click Next → page = 1 → rows 6–10, "Page 2 of 2", Next disabled
pageSize changed 5 → 10 → page resets to 0, "Page 1 of 1", all 10 rows shown
visible = users.slice(page * pageSize, …) — derive it, don't copy rows into state.totalPages - 1. Disable the buttons at the ends.pageSize can leave page pointing past the end — reset page to 0.Pagination looks like it's about "pages," but there are no pages — there's one array and a moving window over it. Keep two numbers in state, page and pageSize, and everything you render is sliced from those.
You have a full list of users that's too long to show at once. You want to show a handful at a time and let the user step through. The instinct is to think in terms of "page 1's rows," "page 2's rows" — but copying rows into per-page buckets means keeping those buckets in sync with the source. Far simpler: the source list never moves; you just compute which slice of it to display from the current page number and how many rows fit on a page.
Two state values: page (0-based) and pageSize. From them, everything else is derived on render:
totalPages = Math.ceil(users.length / pageSize) — how many slices the list breaks into.start = page * pageSize — the index of the first row on this page.visible = users.slice(start, start + pageSize) — the rows to render.The table body maps visible; the controls read page and totalPages. Nothing is stored that can drift from the source array.
A tempting first attempt slices the array and stores the result:
const [rows, setRows] = useState(users.slice(0, 5));
function next() {
setRows(users.slice(5, 10)); // …and now page number lives where?
}
Now the rows are state, but the page number isn't — so "Page X of Y," disabling Next at the end, and resetting on a page-size change all have nowhere to read from. You end up tracking an index anyway, plus a redundant copy of the rows. Storing the two numbers and deriving the rows is both less state and less to keep consistent.
import { useState } from 'react';
import './styles.css';
const USERS = [
{ id: 1, name: 'Ada Lovelace', age: 36, occupation: 'Mathematician' },
{ id: 2, name: 'Alan Turing', age: 41, occupation: 'Computer Scientist' },
{ id: 3, name: 'Grace Hopper', age: 85, occupation: 'Rear Admiral' },
{ id: 4, name: 'Katherine Johnson', age: 101, occupation: 'Mathematician' },
{ id: 5, name: 'Linus Torvalds', age: 54, occupation: 'Software Engineer' },
{ id: 6, name: 'Margaret Hamilton', age: 87, occupation: 'Engineer' },
{ id: 7, name: 'Tim Berners-Lee', age: 69, occupation: 'Inventor' },
{ id: 8, name: 'Dennis Ritchie', age: 70, occupation: 'Computer Scientist' },
{ id: 9, name: 'Barbara Liskov', age: 85, occupation: 'Professor' },
{ id: 10, name: 'Guido van Rossum', age: 68, occupation: 'Programmer' },
];
export default function App() {
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(5);
const totalPages = Math.ceil(USERS.length / pageSize);
const start = page * pageSize;
const visible = USERS.slice(start, start + pageSize);
function changePageSize(n: number) {
setPageSize(n);
setPage(0); // a smaller list means the old page may not exist
}
return (
<main className="container">
<h1>Data Table</h1>
<table>
<thead>
<tr>
<th>Name</th>
<th>Age</th>
<th>Occupation</th>
</tr>
</thead>
<tbody>
{visible.map((u) => (
<tr key={u.id}>
<td>{u.name}</td>
<td>{u.age}</td>
<td>{u.occupation}</td>
</tr>
))}
</tbody>
</table>
<div className="pager">
<button onClick={() => setPage((p) => Math.max(0, p - 1))} disabled={page === 0}>
Prev
</button>
<span>
Page {page + 1} of {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page === totalPages - 1}
>
Next
</button>
<select value={pageSize} onChange={(e) => changePageSize(Number(e.target.value))}>
<option value={5}>5 / page</option>
<option value={10}>10 / page</option>
</select>
</div>
</main>
);
}
The body maps visible, never the full USERS. The buttons use the functional updater setPage((p) => …) so they always move from the latest page, and each is clamped — Math.max(0, …) and Math.min(totalPages - 1, …) — so the window can't slide off either end. key={u.id} keys rows by a stable id, not by array index, so React tracks the right row as the page changes. And changePageSize resets page to 0, because page 1 of a 5-row list doesn't exist once you show 10 per page.
Start at page = 0, pageSize = 5, 10 users.
totalPages = ceil(10/5) = 2, start = 0, visible = USERS.slice(0, 5) → rows 1–5. Prev is disabled (page === 0); the label reads "Page 1 of 2."setPage((p) => Math.min(1, 1)) = 1. Re-render: start = 5, visible = USERS.slice(5, 10) → rows 6–10. Now Next is disabled (page === totalPages - 1), Prev is enabled, label "Page 2 of 2."changePageSize(10) sets pageSize = 10 and page = 0. totalPages = ceil(10/10) = 1, visible = USERS.slice(0, 10) → all rows. Both buttons disabled, "Page 1 of 1." Had we not reset page, it would still be 1 and start = 10 would slice an empty window.page + pageSize; derive visible.Math.min/Math.max and disable at the ends.pageSize can strand page past the end. Fix: setPage(0) whenever pageSize changes.key={u.id}.page is 0-based; display page + 1.page and pageSize as query params.The reducer owns every valid pagination transition. Rows remain derived from the current snapshot, and resizing resets the page in the same atomic update.
import { useReducer } from 'react';
import './styles.css';
const USERS = [
{ id: 1, name: 'Ada Lovelace', age: 36, occupation: 'Mathematician' },
{ id: 2, name: 'Alan Turing', age: 41, occupation: 'Computer Scientist' },
{ id: 3, name: 'Grace Hopper', age: 85, occupation: 'Rear Admiral' },
{ id: 4, name: 'Katherine Johnson', age: 101, occupation: 'Mathematician' },
{ id: 5, name: 'Linus Torvalds', age: 54, occupation: 'Software Engineer' },
{ id: 6, name: 'Margaret Hamilton', age: 87, occupation: 'Engineer' },
{ id: 7, name: 'Tim Berners-Lee', age: 69, occupation: 'Inventor' },
{ id: 8, name: 'Dennis Ritchie', age: 70, occupation: 'Computer Scientist' },
{ id: 9, name: 'Barbara Liskov', age: 85, occupation: 'Professor' },
{ id: 10, name: 'Guido van Rossum', age: 68, occupation: 'Programmer' },
];
type Pagination = { page: number; pageSize: number };
type Action = { type: 'prev' | 'next'; totalPages: number } | { type: 'resize'; pageSize: number };
function paginate(state: Pagination, action: Action): Pagination {
if (action.type === 'resize') return { page: 0, pageSize: action.pageSize };
const delta = action.type === 'next' ? 1 : -1;
return { ...state, page: Math.max(0, Math.min(action.totalPages - 1, state.page + delta)) };
}
export default function App() {
const [pagination, send] = useReducer(paginate, { page: 0, pageSize: 5 });
const totalPages = Math.ceil(USERS.length / pagination.pageSize);
const start = pagination.page * pagination.pageSize;
const visible = USERS.slice(start, start + pagination.pageSize);
return (
<main className="container">
<h1>Data Table</h1>
<table>
<thead><tr><th>Name</th><th>Age</th><th>Occupation</th></tr></thead>
<tbody>{visible.map((user) => (
<tr key={user.id}><td>{user.name}</td><td>{user.age}</td><td>{user.occupation}</td></tr>
))}</tbody>
</table>
<div className="pager">
<button onClick={() => send({ type: 'prev', totalPages })} disabled={pagination.page === 0}>Prev</button>
<span>Page {pagination.page + 1} of {totalPages}</span>
<button onClick={() => send({ type: 'next', totalPages })} disabled={pagination.page === totalPages - 1}>Next</button>
<select value={pagination.pageSize} onChange={(event) => send({ type: 'resize', pageSize: Number(event.target.value) })}>
<option value={5}>5 / page</option><option value={10}>10 / page</option>
</select>
</div>
</main>
);
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Build a paginated data table: a fixed list of users shown a page at a time, with Prev/Next buttons, a "Page X of Y" indicator, and a page-size selector. The list itself never changes — what changes is the window of rows you show. The core idea is that the visible rows are a slice of the full array, and the slice bounds come from two numbers: the current page and the page size.
// A self-contained component. No props.
function App(): JSX.Element;
A <table> showing one page of users, plus pagination controls.
10 users, pageSize = 5, page = 0 → rows 1–5, "Page 1 of 2"
click Next → page = 1 → rows 6–10, "Page 2 of 2", Next disabled
pageSize changed 5 → 10 → page resets to 0, "Page 1 of 1", all 10 rows shown
visible = users.slice(page * pageSize, …) — derive it, don't copy rows into state.totalPages - 1. Disable the buttons at the ends.pageSize can leave page pointing past the end — reset page to 0.Pagination looks like it's about "pages," but there are no pages — there's one array and a moving window over it. Keep two numbers in state, page and pageSize, and everything you render is sliced from those.
You have a full list of users that's too long to show at once. You want to show a handful at a time and let the user step through. The instinct is to think in terms of "page 1's rows," "page 2's rows" — but copying rows into per-page buckets means keeping those buckets in sync with the source. Far simpler: the source list never moves; you just compute which slice of it to display from the current page number and how many rows fit on a page.
Two state values: page (0-based) and pageSize. From them, everything else is derived on render:
totalPages = Math.ceil(users.length / pageSize) — how many slices the list breaks into.start = page * pageSize — the index of the first row on this page.visible = users.slice(start, start + pageSize) — the rows to render.The table body maps visible; the controls read page and totalPages. Nothing is stored that can drift from the source array.
A tempting first attempt slices the array and stores the result:
const [rows, setRows] = useState(users.slice(0, 5));
function next() {
setRows(users.slice(5, 10)); // …and now page number lives where?
}
Now the rows are state, but the page number isn't — so "Page X of Y," disabling Next at the end, and resetting on a page-size change all have nowhere to read from. You end up tracking an index anyway, plus a redundant copy of the rows. Storing the two numbers and deriving the rows is both less state and less to keep consistent.
import { useState } from 'react';
import './styles.css';
const USERS = [
{ id: 1, name: 'Ada Lovelace', age: 36, occupation: 'Mathematician' },
{ id: 2, name: 'Alan Turing', age: 41, occupation: 'Computer Scientist' },
{ id: 3, name: 'Grace Hopper', age: 85, occupation: 'Rear Admiral' },
{ id: 4, name: 'Katherine Johnson', age: 101, occupation: 'Mathematician' },
{ id: 5, name: 'Linus Torvalds', age: 54, occupation: 'Software Engineer' },
{ id: 6, name: 'Margaret Hamilton', age: 87, occupation: 'Engineer' },
{ id: 7, name: 'Tim Berners-Lee', age: 69, occupation: 'Inventor' },
{ id: 8, name: 'Dennis Ritchie', age: 70, occupation: 'Computer Scientist' },
{ id: 9, name: 'Barbara Liskov', age: 85, occupation: 'Professor' },
{ id: 10, name: 'Guido van Rossum', age: 68, occupation: 'Programmer' },
];
export default function App() {
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(5);
const totalPages = Math.ceil(USERS.length / pageSize);
const start = page * pageSize;
const visible = USERS.slice(start, start + pageSize);
function changePageSize(n: number) {
setPageSize(n);
setPage(0); // a smaller list means the old page may not exist
}
return (
<main className="container">
<h1>Data Table</h1>
<table>
<thead>
<tr>
<th>Name</th>
<th>Age</th>
<th>Occupation</th>
</tr>
</thead>
<tbody>
{visible.map((u) => (
<tr key={u.id}>
<td>{u.name}</td>
<td>{u.age}</td>
<td>{u.occupation}</td>
</tr>
))}
</tbody>
</table>
<div className="pager">
<button onClick={() => setPage((p) => Math.max(0, p - 1))} disabled={page === 0}>
Prev
</button>
<span>
Page {page + 1} of {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page === totalPages - 1}
>
Next
</button>
<select value={pageSize} onChange={(e) => changePageSize(Number(e.target.value))}>
<option value={5}>5 / page</option>
<option value={10}>10 / page</option>
</select>
</div>
</main>
);
}
The body maps visible, never the full USERS. The buttons use the functional updater setPage((p) => …) so they always move from the latest page, and each is clamped — Math.max(0, …) and Math.min(totalPages - 1, …) — so the window can't slide off either end. key={u.id} keys rows by a stable id, not by array index, so React tracks the right row as the page changes. And changePageSize resets page to 0, because page 1 of a 5-row list doesn't exist once you show 10 per page.
Start at page = 0, pageSize = 5, 10 users.
totalPages = ceil(10/5) = 2, start = 0, visible = USERS.slice(0, 5) → rows 1–5. Prev is disabled (page === 0); the label reads "Page 1 of 2."setPage((p) => Math.min(1, 1)) = 1. Re-render: start = 5, visible = USERS.slice(5, 10) → rows 6–10. Now Next is disabled (page === totalPages - 1), Prev is enabled, label "Page 2 of 2."changePageSize(10) sets pageSize = 10 and page = 0. totalPages = ceil(10/10) = 1, visible = USERS.slice(0, 10) → all rows. Both buttons disabled, "Page 1 of 1." Had we not reset page, it would still be 1 and start = 10 would slice an empty window.page + pageSize; derive visible.Math.min/Math.max and disable at the ends.pageSize can strand page past the end. Fix: setPage(0) whenever pageSize changes.key={u.id}.page is 0-based; display page + 1.page and pageSize as query params.The reducer owns every valid pagination transition. Rows remain derived from the current snapshot, and resizing resets the page in the same atomic update.
import { useReducer } from 'react';
import './styles.css';
const USERS = [
{ id: 1, name: 'Ada Lovelace', age: 36, occupation: 'Mathematician' },
{ id: 2, name: 'Alan Turing', age: 41, occupation: 'Computer Scientist' },
{ id: 3, name: 'Grace Hopper', age: 85, occupation: 'Rear Admiral' },
{ id: 4, name: 'Katherine Johnson', age: 101, occupation: 'Mathematician' },
{ id: 5, name: 'Linus Torvalds', age: 54, occupation: 'Software Engineer' },
{ id: 6, name: 'Margaret Hamilton', age: 87, occupation: 'Engineer' },
{ id: 7, name: 'Tim Berners-Lee', age: 69, occupation: 'Inventor' },
{ id: 8, name: 'Dennis Ritchie', age: 70, occupation: 'Computer Scientist' },
{ id: 9, name: 'Barbara Liskov', age: 85, occupation: 'Professor' },
{ id: 10, name: 'Guido van Rossum', age: 68, occupation: 'Programmer' },
];
type Pagination = { page: number; pageSize: number };
type Action = { type: 'prev' | 'next'; totalPages: number } | { type: 'resize'; pageSize: number };
function paginate(state: Pagination, action: Action): Pagination {
if (action.type === 'resize') return { page: 0, pageSize: action.pageSize };
const delta = action.type === 'next' ? 1 : -1;
return { ...state, page: Math.max(0, Math.min(action.totalPages - 1, state.page + delta)) };
}
export default function App() {
const [pagination, send] = useReducer(paginate, { page: 0, pageSize: 5 });
const totalPages = Math.ceil(USERS.length / pagination.pageSize);
const start = pagination.page * pagination.pageSize;
const visible = USERS.slice(start, start + pagination.pageSize);
return (
<main className="container">
<h1>Data Table</h1>
<table>
<thead><tr><th>Name</th><th>Age</th><th>Occupation</th></tr></thead>
<tbody>{visible.map((user) => (
<tr key={user.id}><td>{user.name}</td><td>{user.age}</td><td>{user.occupation}</td></tr>
))}</tbody>
</table>
<div className="pager">
<button onClick={() => send({ type: 'prev', totalPages })} disabled={pagination.page === 0}>Prev</button>
<span>Page {pagination.page + 1} of {totalPages}</span>
<button onClick={() => send({ type: 'next', totalPages })} disabled={pagination.page === totalPages - 1}>Next</button>
<select value={pagination.pageSize} onChange={(event) => send({ type: 'resize', pageSize: Number(event.target.value) })}>
<option value={5}>5 / page</option><option value={10}>10 / page</option>
</select>
</div>
</main>
);
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.