Pagination is one of the most-built UI components. The interview tests whether you understand the modes (page numbers vs cursor vs infinite scroll), the data fetching patterns, and the accessibility considerations.
The three modes
Page-number pagination
“Page 1 of 47.” Classic. Best for:
- Search results
- Catalogs where users want to jump to specific pages
- Content where total count is meaningful
Cursor-based
“Show me 20 more results after id=abc123.” Best for:
- Infinite scroll
- Live data where order may change (social feeds, notifications)
- Large datasets where COUNT is expensive
Infinite scroll
UI variation of cursor-based — load more as user scrolls. Best for:
- Browsing / exploration UX
- Mobile feeds
Worst for: tasks that require finding specific item later (no permalink to “page 27”).
Page-number implementation
const TOTAL = totalCount;
const PER_PAGE = 20;
const totalPages = Math.ceil(TOTAL / PER_PAGE);
const items = data.slice((page-1)*PER_PAGE, page*PER_PAGE);
Server-side: SELECT * FROM items LIMIT 20 OFFSET (page-1)*20.
Caveat: OFFSET pagination is slow on large tables and breaks under inserts.
Cursor-based implementation
Server returns items + cursor for next page:
{ items: [...], nextCursor: "id_after_last" }
Client requests next:
fetch(`/items?cursor=${nextCursor}&limit=20`)
Server: SELECT * FROM items WHERE id > cursor ORDER BY id LIMIT 20. Fast and stable under inserts.
Page navigator UI
Common pattern: First, Prev, [1] [2] [3] … [47], Next, Last.
For many pages, ellipsis to skip middle: 1, 2, 3, …, 22, 23, 24.
Page jump input: “Go to page _” for power users.
URL state
The current page belongs in the URL. ?page=3:
- Shareable — send page-3 link to a colleague
- Browser back works
- Refresh preserves position
Tools: nuqs for typed query-param management.
Infinite scroll triggering
Use IntersectionObserver:
const sentinelRef = useRef();
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) loadMore();
});
observer.observe(sentinelRef.current);
return () => observer.disconnect();
}, []);
Trigger before bottom (sentinel ~3 viewports up).
The “back from detail” problem
User scrolls infinite feed, taps an item, comes back. Where do they land? Top of feed (loses position, frustrating) or restored position?
Pattern: virtual list + scroll restoration. Save position on navigate-out; restore on navigate-in. React Router’s ScrollRestoration helps.
Loading states
- Initial load: skeleton or spinner
- Loading more (infinite): inline loader at bottom
- Page change: optionally fade or skeleton during fetch
Accessibility
<nav aria-label="Pagination">wrapper- Current page: aria-current=”page”
- Page links are real
<a>with hrefs (so middle-click opens new tab) - For infinite scroll: announce “Loaded 20 more” via aria-live
Common mistakes
- OFFSET pagination on large tables (slow)
- Page state not in URL (refresh loses position)
- Infinite scroll without scroll restoration
- “Page 1 of N” with N computed via expensive COUNT
- No loading state — UI looks frozen
Frequently Asked Questions
Page numbers or infinite scroll?
Page numbers for catalogs and search. Infinite scroll for feeds. Hybrid (load more button) for the middle ground.
What about TanStack Query for pagination?
useInfiniteQuery handles cursor-based pagination beautifully. Manages cache, fetching, and state.
How do I show total count without expensive COUNT?
Approximate count (PostgreSQL pg_class.reltuples). Or skip the count entirely; show “many results” or “next page available.”