Pagination, Infinite Scroll and Search
AdvancedOffset pagination is simple and drifts under writes; cursor pagination is stable and cannot jump to page seven. Search adds debouncing and cancellation on top.
Overview
Long lists are the most common data-fetching feature after a plain list, and the choice between offset and cursor pagination has consequences the frontend feels. Offset is easy to reason about and gives you numbered pages, but if a row is inserted while the user is reading, page two silently repeats an item from page one. Cursor pagination is stable across writes and is what infinite scroll wants, at the cost of not being able to jump to an arbitrary page. Search then layers on debouncing, cancellation and keeping the previous results visible while typing.
Page-Based, Without the Flicker
Keep the previous page on screen while the next one loads.
const { data, isPlaceholderData } = useQuery({
queryKey: ['problems', { topic, page }],
queryFn: () => listProblems({ topic, page }),
placeholderData: keepPreviousData, // hold the old page during the fetch
})
<ProblemList problems={data.items} dimmed={isPlaceholderData} />
<Pagination
page={page}
total={data.totalPages}
onChange={setPage}
disabled={isPlaceholderData}
/>
// Without keepPreviousData the list unmounts on every page change
// and the page jumps to a skeleton — the classic flicker.
// Put the page in the URL, not in state, so back/forward and a
// shared link both work:
const [params, setParams] = useSearchParams()
const page = Number(params.get('page') ?? 1)
// And always reset to page 1 when a filter changes, or the user
// lands on an out-of-range page with no results.Infinite Scroll
Cursor-based, with an IntersectionObserver sentinel rather than a scroll listener.
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({
queryKey: ['feed'],
queryFn: ({ pageParam }) => getFeed({ cursor: pageParam }),
initialPageParam: null,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
})
const rows = data.pages.flatMap(p => p.items)
// The sentinel — see the JavaScript track's observers concept
const ref = useRef(null)
useEffect(() => {
if (!hasNextPage) return
const io = new IntersectionObserver(
([entry]) => entry.isIntersecting && fetchNextPage(),
{ rootMargin: '400px' }, // load before the user arrives
)
io.observe(ref.current)
return () => io.disconnect()
}, [hasNextPage, fetchNextPage])
<div ref={ref} aria-hidden />
{isFetchingNextPage && <Spinner />}
// Always provide a "Load more" button as well — infinite scroll
// alone is unreachable by keyboard and traps screen-reader users
// before the footer.Search
Debounce the request, not the input, and let the library handle the race.
const [query, setQuery] = useState('') // updates instantly
const debounced = useDebouncedValue(query, 300) // what the request uses
const { data, isFetching } = useQuery({
queryKey: ['search', debounced],
queryFn: ({ signal }) => search(debounced, { signal }),
enabled: debounced.length >= 2, // do not search one character
placeholderData: keepPreviousData,
})
// The input stays controlled and responsive because it is driven by
// 'query'. Only the fetch waits.
function useDebouncedValue(value, ms) {
const [v, setV] = useState(value)
useEffect(() => {
const id = setTimeout(() => setV(value), ms)
return () => clearTimeout(id) // cleanup cancels the pending one
}, [value, ms])
return v
}
// Put the query in the URL too — a search result page should be
// shareable, and the back button should return to the previous search.Key Points to Remember
- 1Offset pagination can repeat or skip rows when the underlying data changes; cursor pagination is stable
- 2keepPreviousData holds the current page on screen so changing pages does not flicker to a skeleton
- 3Page, filter and search state belong in the URL so links, refresh and the back button all work
- 4Infinite scroll uses an IntersectionObserver sentinel with rootMargin, and still needs a "Load more" button for accessibility
- 5Debounce the value the query depends on, not the input itself, so typing stays responsive
Interview Questions
Sign in to ask AriaWhat is the trade-off between offset and cursor pagination?
How do you stop a paginated list flickering when the page changes?
Why is infinite scroll alone an accessibility problem?
Ask Aria about Pagination, Infinite Scroll and Search
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.