Topic 24.2
Infinite Scroll, Load More and Pagination
In one line
Long collections arrive in pages: numbered pagination, a Load more button, or infinite scroll that fetches the next page when an invisible sentinel element comes into view; doing it right means cursors instead of offsets, one request at a time, de-duplication, an end state, retry on errors and bringing users back to the same place.
Think of it like this
A waiter bringing food. Nobody brings all 40 dishes of a buffet to your table at once. You get a plate, and when it's nearly empty, the waiter notices and brings the next one. Infinite scroll is the waiter watching your plate: when you get near the bottom of the list, the page fetches the next batch by itself. Load more is you raising your hand. Pagination is a menu with numbered pages.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Page
- One batch of items from a list API, plus what you need to ask for the next batch.
- Offset pagination
- Asking for items by position: skip N, take M (
?offset=20&limit=10). - Cursor pagination
- Asking for the items after a bookmark the server gave you (
?after=abc123). - Cursor
- An opaque bookmark (often the last item's id or an encoded sort key) that marks where the next page starts.
- Sentinel
- An invisible element after the last item. When it becomes visible, it's time to load more.
- IntersectionObserver
- A browser API that tells you when an element enters or leaves the visible area of a page or a scroll box.
- rootMargin
- Extra space added around the observed area, so the callback fires before the element is really visible.
- De-duplication
- Dropping items you already have (same id) when adding a new page.
Step by step
01Start with a paged API
Whatever the UI, the server answers { items, nextCursor }. nextCursor: null means there are no more items.
{
"items": [{ "id": 21, "title": "Post 21" }, { "id": 22, "title": "Post 22" }],
"nextCursor": "22"
}02Put a sentinel after the list
Render the items, then one more element. The observer watches it with the scroll box as root and a bottom rootMargin, so loading starts a little early.
03Guard, append, de-duplicate
Check a loading ref before starting. Append with the updater form (setPosts(old => ...)) and filter out ids you already have.
setPosts((old) => {
const seen = new Set(old.map((p) => p.id));
return [...old, ...page.items.filter((p) => !seen.has(p.id))];
});04Handle the end and errors
When nextCursor is null, stop observing and show an end message. On an error, stop observing and show Retry: otherwise the still-visible sentinel would fire again and hammer a failing server.
05Remember pages and position
Keep pages in a cache that outlives the list (TanStack Query's useInfiniteQuery), and save the scroll position while scrolling. On return, the cached pages render at once and useLayoutEffect restores scrollTop before paint.
06Pick the UI for the job
Feeds: infinite scroll (or Load more). Search results, shops and admin tables: numbered pages with real URLs. Always keep crawlable <a href="?page=2"> links if the list matters for search engines.
Try it yourself
- 1
Remove the loading guard
In the first example, delete the line
if (loading.current || status === "done") return;and changerootMarginto"0px 0px 400px 0px". Predict: can two requests for the same cursor start? Watch the log for the same cursor twice. The de-duplication keeps the screen correct, but the server did double work. - 2
Turn off the error stop
Make the fake API fail every time for page 3 (remove
&& !failedOnce) and removestatus === "error" ||from the effect. Predict what the log does. Every render re-creates the observer, the sentinel is still visible, and the failing request repeats forever. - 3
Turn off staleTime
In the useInfiniteQuery example, delete the
staleTimeline. Predict the fetch count after Back. Stale infinite queries refetch every loaded page in order on remount, so the count grows by 3.
Code & diagrams
The script scrolls the box to the bottom to trigger loads. The third page fails once, the observer stops, and only the Retry click loads it. After the last page the sentinel shows the end message and stops observing. Scroll by hand after it runs, or press Run and scroll yourself.
Preview
Console
Nothing logged yet.Expected console output
Loaded 10 posts after cursor null
Loaded 10 posts after cursor 9
Failed: Network error
Loaded 10 posts after cursor 19
Loaded 5 posts after cursor 29
Posts on screen: 35. You're all caught upPage 1 is posts 8, 7, 6 for both. A new post makes the offset API return 6 again (a duplicate). A deleted post makes it skip 5 (the user never sees it). The cursor API returns 5, 4, 3 both times.
Preview
Console
Nothing logged yet.Expected console output
New post 9 added | offset page 2: 6,5,4 | cursor page 2: 5,4,3
Post 7 deleted | offset page 2: 4,3,2 | cursor page 2: 5,4,3Three pages are loaded, the feed is scrolled to 600px and a post is opened (the Feed unmounts). After Back, the 30 posts come from the cache (still 3 fetches) and the scroll position is restored before paint.
Preview
Console
Nothing logged yet.Expected console output
Before: 30 posts, scrollTop 600, fetches 3
After back: 30 posts, scrollTop 600, fetches 3import { useEffect } from "react";
import { useInView } from "react-intersection-observer";
import { useInfiniteQuery } from "@tanstack/react-query";
export function Feed() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ["feed"],
queryFn: ({ pageParam }) => fetch("/api/posts?after=" + (pageParam ?? "")).then((r) => r.json()),
initialPageParam: null,
getNextPageParam: (lastPage) => lastPage.nextCursor, // null or undefined = no next page
});
// ref goes on the sentinel; inView flips to true when it enters (200px early).
const { ref, inView } = useInView({ rootMargin: "0px 0px 200px 0px" });
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage) fetchNextPage();
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage]);
return (
<>
{data?.pages.flatMap((p) => p.items).map((post) => <article key={post.id}>{post.title}</article>)}
<div ref={ref} aria-live="polite">{isFetchingNextPage ? "Loading…" : hasNextPage ? "" : "The end"}</div>
</>
);
}// Server-rendered list page: /products?page=2 works with no JavaScript at all.
export function Pager({ page, totalPages }) {
return (
<nav aria-label="Pagination">
{page > 1 && <a href={"?page=" + (page - 1)} rel="prev">Previous</a>}
<span aria-current="page">Page {page} of {totalPages}</span>
{page < totalPages && <a href={"?page=" + (page + 1)} rel="next">Next</a>}
</nav>
);
}
// Search engines follow these links. On the client you can still intercept them
// and append the next page in place, updating the URL so a refresh or a shared
// link lands on the same page.Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Using the array index as the key with offset pagination
In the infinite scroll example, render posts with key={index} and let the API return a duplicate post on page 2.
Break #2
Observing the window instead of the scroll box
Remove root: boxRef.current from the IntersectionObserver options while the list lives in its own overflow: auto box.
Myth vs fact
Myth
Infinite scroll is always better UX than pages.
Fact
It's good for aimless browsing. For finding and comparing (shops, search, tables), pages are easier to navigate, remember and share, and they keep the footer reachable.
Myth
Google scrolls infinite pages like a user.
Fact
Crawlers don't scroll or click Load more reliably. Content only reachable that way may not be indexed. Give each page a real URL linked with <a href>.
Myth
De-duplication is only needed with buggy servers.
Fact
Offset drift, retries, double triggers and live inserts all produce duplicates with a correct server. De-duplicate by id every time you append.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
A good cursor encodes the sort key, not a position: for a feed sorted by
(createdAt desc, id desc)the cursor is both values, and the query isWHERE (created_at, id) < (:c, :id) ORDER BY ... LIMIT 20. It stays correct with inserts and uses an index seek, whereasOFFSET 100000makes the database walk and throw away 100,000 rows. - ▸
useInfiniteQuerystorespagesandpageParams. With a stale cache it refetches every page sequentially (each needs the previous cursor), which is slow for deep lists; usestaleTime, ormaxPagesto keep only the last N pages in memory (then also definegetPreviousPageParamso earlier pages can be loaded back). - ▸
IntersectionObserver callbacks run asynchronously (in a task after layout), never in the middle of a scroll, so they don't block scrolling the way a heavy
scrolllistener can. A callback also fires once right afterobserve()with the current state, which is why a short first page keeps loading until the sentinel leaves the area. - ▸
Combine infinite loading with virtualisation (Topic 24.1) for long sessions: the virtualiser's last rendered index replaces the sentinel (
if (lastIndex >= items.length - 5) fetchNextPage()), so memory and DOM stay bounded.
Remember this
- 1
Why pages at all. A server can't send 100,000 posts in one response: it would be slow to build, slow to download and too much for the page (Topic 24.1). So APIs return a page: a small batch (say 20 items) plus the information needed to ask for the next one. The three UIs (numbered pages, Load more, infinite scroll) all sit on the same paged API.
- 2
Offset vs cursor pagination. An offset API says "skip 20, take 10" (
?offset=20&limit=10, or?page=3). It's simple and lets you jump to page 7, but if items are added or deleted between requests, the window slides: a new post at the top pushes an item you already have into the next page (a duplicate), and a deleted one pulls an item you haven't seen into the previous page (a skipped item). A cursor API says "give me 10 after the last item I have" (?after=abc123); the server returns the items and anextCursor. Cursors are stable on changing data and fast on big tables (the database seeks to the cursor instead of counting skipped rows; see /system-design for API design), but you can't jump to an arbitrary page. - 3
The sentinel and IntersectionObserver. Listening to every scroll event and comparing pixel positions works but is fiddly and runs on every frame. Instead, put an empty sentinel element after the last item and watch it with an IntersectionObserver: a browser API that calls you back (asynchronously, off the scroll path) when an element enters or leaves an area. Set
rootto the scroll container (or leave itnullfor the whole window) androotMargin: "0px 0px 200px 0px"to grow the area downwards, so loading starts 200px before the user actually reaches the end. - 4
The loading rules that prevent bugs. (1) One request at a time: the observer can fire again before the first page arrives, so guard with a ref flag (a ref changes instantly, state only on the next render). (2) De-duplicate by id when appending, because retries, double triggers and offset drift all produce duplicates, and duplicate keys break React's list updates. (3) End of list: when
nextCursorisnull, stop observing and say so ("You're all caught up"). (4) Errors: stop auto-loading, show a Retry button, and don't retry in a loop. (5) Announce loading and the end with anaria-liveregion so screen-reader users know new content arrived. - 5
Coming back to the same place. A user scrolls 80 items deep, opens one, presses Back, and expects to be where they were. That needs two things to survive the list unmounting: the loaded pages (keep them in a cache such as TanStack Query's
useInfiniteQuery, Topic 14.6, or in state above the route) and the scroll position (savescrollTopwhile scrolling, restore it inuseLayoutEffectonce the data is back). Topic 24.3 covers scroll restoration across routes. - 6
When pagination beats infinite scroll. Infinite scroll suits endless browsing with no goal: social feeds, image galleries. Numbered pagination is better when people search for something and compare (shop results, search results, tables): they can remember "it was on page 3", share a link to a page, and reach the footer. Infinite scroll also hides the footer forever, makes "how much is left" unknowable, and is hard for keyboard users (tabbing past the last item triggers more loading). For SEO, search engines don't scroll or click Load more: give every page a real URL (
/products?page=2) with ordinary<a href>links between them, even if JavaScript turns them into infinite scroll. A Load more button is the safe middle ground: user-controlled, keyboard-friendly, footer reachable.
Explain it without notes
Compare offset and cursor pagination. When would you choose each?
How do you implement infinite scroll without listening to scroll events?
List the bugs a naive infinite scroll has and how to prevent each.
When should a product use numbered pagination instead of infinite scroll?
Practice
Write appendUnique(oldItems, newItems) that appends without duplicate ids and keeps the old order.
Change the first example to a Load more button instead of a sentinel (keep the guard, end and retry).
Trade-offs
- ↔
Infinite scroll keeps people browsing with no clicks, but hides the footer, loses the sense of progress, is harder for keyboard and screen-reader users, and needs extra work for SEO and Back navigation. Load more and numbered pages give users control.
- ↔
Cursor pagination is correct and fast on live data but can't jump to page N or show a total cheaply. Offset pagination is simple and jumpable but drifts on changing data and slows down deep in big tables.
Done when you can
I can build infinite scroll with a sentinel and an IntersectionObserver rooted in the scroll box.
I can prevent double loads, duplicates, endless requests and retry storms.
I can explain offset vs cursor pagination and show the drift bug.
I can use useInfiniteQuery with a Load more button.
I can bring users back to the same list position after navigation.
I can argue when numbered pagination is the better choice, including for SEO.