Command Palette

Search for a command to run...

Hectal
PHASE 24Advanced ~32 min· topic 2 of 10

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.

api.jsonwhole filejson
{
  "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.

Put a sentinel after the listdiagram
Rendering diagram…

03Guard, append, de-duplicate

Check a loading ref before starting. Append with the updater form (setPosts(old => ...)) and filter out ids you already have.

append.jswhole filejavascript
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. 1

    Remove the loading guard

    In the first example, delete the line if (loading.current || status === "done") return; and change rootMargin to "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. 2

    Turn off the error stop

    Make the fake API fail every time for page 3 (remove && !failedOnce) and remove status === "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. 3

    Turn off staleTime

    In the useInfiniteQuery example, delete the staleTime line. 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

Infinite scroll from scratch: sentinel, guard, de-duplication, retry and end New tab

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

Press Run to see the page.

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 up
Why cursors beat offsets when data changes New tab

Page 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

Press Run to see the page.

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,3
Load more with useInfiniteQuery, and coming back to the same place New tab

Three 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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Before: 30 posts, scrollTop 600, fetches 3
After back: 30 posts, scrollTop 600, fetches 3
The sentinel with react-intersection-observer (not live: install react-intersection-observer)jsx
import { 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>
    </>
  );
}
Crawlable pagination that JavaScript can enhancejsx
// 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.

terminal
$ Preview
── what you'll see ──
The duplicate appears twice in the list, and if a post is removed, the rows below it keep the wrong state (a checked box moves to the next post).

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.

terminal
$ Preview
── what you'll see ──
Pages load one after another without any scrolling until everything is loaded (or never load, depending on the layout).

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 is WHERE (created_at, id) < (:c, :id) ORDER BY ... LIMIT 20. It stays correct with inserts and uses an index seek, whereas OFFSET 100000 makes the database walk and throw away 100,000 rows.

  • ▸

    useInfiniteQuery stores pages and pageParams. With a stale cache it refetches every page sequentially (each needs the previous cursor), which is slow for deep lists; use staleTime, or maxPages to keep only the last N pages in memory (then also define getPreviousPageParam so 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 scroll listener can. A callback also fires once right after observe() 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. 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. 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 a nextCursor. 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. 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 root to the scroll container (or leave it null for the whole window) and rootMargin: "0px 0px 200px 0px" to grow the area downwards, so loading starts 200px before the user actually reaches the end.

  4. 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 nextCursor is null, 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 an aria-live region so screen-reader users know new content arrived.

  5. 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 (save scrollTop while scrolling, restore it in useLayoutEffect once the data is back). Topic 24.3 covers scroll restoration across routes.

  6. 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

01

Compare offset and cursor pagination. When would you choose each?

02

How do you implement infinite scroll without listening to scroll events?

03

List the bugs a naive infinite scroll has and how to prevent each.

04

When should a product use numbered pagination instead of infinite scroll?

Practice

01

Write appendUnique(oldItems, newItems) that appends without duplicate ids and keeps the old order.

02

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.