JavaScript and React course Β· Module 11: Data Fetching and APIs

Pagination and Infinite Scroll

6 min read
In this lesson3

When our cosmic catalog contains thousands of objects, we can't fetch them all at once: that would overload both the server and the browser, and the user will see only the first few anyway. The solution is pagination, splitting data into smaller chunks (pages). In this lesson you'll learn two ways a server splits data and three ways the interface loads it: page buttons, a "Load more" button and automatic infinite scroll.

Two Types of Pagination

1. Offset Pagination (Traditional)

The server returns a specific number of items starting from a given offset. Most often you pass a page number and a limit, and the server computes the offset as (page - 1) * limit. The SWAPI API we use returns 10 items per page plus a count field with the total number of results:

1// URL: /api/planets?page=1&limit=10
2// Returns items 1-10
3
4// URL: /api/planets?page=2&limit=10
5// Returns items 11-20
6
7function PaginatedList() {
8  const [data, setData] = useState([]);
9  const [page, setPage] = useState(1);
10  const [totalPages, setTotalPages] = useState(1);
11  const [loading, setLoading] = useState(true);
12
13  useEffect(() => {
14    async function fetchPage() {
15      setLoading(true);
16      const response = await fetch(
17        `https://swapi.dev/api/planets/?page=${page}`
18      );
19      const result = await response.json();
20      setData(result.results);
21      setTotalPages(Math.ceil(result.count / 10));
22      setLoading(false);
23    }
24
25    fetchPage();
26  }, [page]);

The page number is ordinary state, and the effect lists it as a dependency, so changing page triggers a new request by itself. We compute the number of pages from count with Math.ceil, because the last page may be incomplete. The second part of the component is the list and the buttons:

1  return (
2    <div>
3      {loading ? (
4        <p>Loading page {page}...</p>
5      ) : (
6        data.map(planet => (
7          <div key={planet.name}>{planet.name}</div>
8        ))
9      )}
10
11      <div className="pagination">
12        <button
13          onClick={() => setPage(p => p - 1)}
14          disabled={page === 1}
15        >
16          Previous
17        </button>
18        <span>Page {page} of {totalPages}</span>
19        <button
20          onClick={() => setPage(p => p + 1)}
21          disabled={page === totalPages}
22        >
23          Next
24        </button>
25      </div>
26    </div>
27  );
28}

The buttons are disabled at the edges, so we never ask for page 0. Each page replaces the previous one in data instead of being appended to it. For brevity we skipped the error handling and AbortController from the previous lessons, add them in real code.

2. Cursor Pagination

Instead of a page number, the server returns a cursor, a pointer to the next chunk of data (for example the id of the last item). It's more efficient for large datasets, because the database doesn't have to skip thousands of rows, and it stays stable when someone adds new items in the meantime:

1function CursorPaginatedList() {
2  const [items, setItems] = useState([]);
3  const [nextCursor, setNextCursor] = useState(null);
4  const [loading, setLoading] = useState(false);
5
6  const loadMore = async () => {
7    setLoading(true);
8    const url = nextCursor
9      ? `/api/stars?cursor=${nextCursor}&limit=20`
10      : '/api/stars?limit=20';
11
12    const response = await fetch(url);
13    const data = await response.json();
14
15    setItems(prev => [...prev, ...data.items]);
16    setNextCursor(data.nextCursor); // null if no more
17    setLoading(false);
18  };
19
20  useEffect(() => {
21    loadMore();
22  }, []);
23
24  return (
25    <div>
26      {items.map(item => (
27        <div key={item.id}>{item.name}</div>
28      ))}
29      {nextCursor && (
30        <button onClick={loadMore} disabled={loading}>
31          {loading ? 'Loading...' : 'Load more'}
32        </button>
33      )}
34    </div>
35  );
36}

Here we append the new items to the old ones with spread: [...prev, ...data.items]. When the server returns a nextCursor of null, the button disappears because there's nothing left to load. The downside is that you can't jump "to page 7", so cursors suit scrolling lists and offsets suit tables with page numbers.

"Load More" Button

SWAPI uses a convenient variant: its response includes a next field with the ready-made address of the next page, or null at the end. So we simply keep the next URL in state:

1function LoadMoreList() {
2  const [planets, setPlanets] = useState([]);
3  const [nextUrl, setNextUrl] = useState(
4    'https://swapi.dev/api/planets/'
5  );
6  const [loading, setLoading] = useState(false);
7
8  const loadMore = async () => {
9    if (!nextUrl || loading) return;
10
11    setLoading(true);
12    try {
13      const response = await fetch(nextUrl);
14      const data = await response.json();
15
16      setPlanets(prev => [...prev, ...data.results]);
17      setNextUrl(data.next); // Next page URL or null
18    } catch (err) {
19      console.error(err);
20    } finally {
21      setLoading(false);
22    }
23  };
24
25  useEffect(() => {
26    loadMore();
27  }, []);
28
29  return (
30    <div>
31      <h2>Planet catalog ({planets.length})</h2>
32      {planets.map(planet => (
33        <div key={planet.name} className="planet-card">
34          <h3>{planet.name}</h3>
35          <p>Climate: {planet.climate}</p>
36        </div>
37      ))}
38      {nextUrl && (
39        <button onClick={loadMore} disabled={loading}>
40          {loading ? 'Scanning...' : 'Discover more planets'}
41        </button>
42      )}
43      {!nextUrl && planets.length > 0 && (
44        <p>The entire galaxy has been scanned!</p>
45      )}
46    </div>
47  );
48}

The guard if (!nextUrl || loading) return protects against a double click and against asking for a page that doesn't exist. In development mode with StrictMode, the initial effect runs twice and the first page may be duplicated. The fix is the ignore flag or AbortController from the race conditions lesson.

Infinite Scroll with IntersectionObserver

Infinite scroll loads data automatically when the user reaches the end of the list. It relies on IntersectionObserver, a native browser API that notifies you when an observed element enters the visible area (the viewport). We keep the observer in useRef so it survives renders, and attach a ref callback to the last item, a function React calls with the DOM node:

1import { useRef, useCallback } from 'react';
2
3function InfiniteScrollList() {
4  const [items, setItems] = useState([]);
5  const [page, setPage] = useState(1);
6  const [hasMore, setHasMore] = useState(true);
7  const [loading, setLoading] = useState(false);
8
9  const observer = useRef();
10
11  // Ref callback - fires when the last element
12  // appears on screen
13  const lastItemRef = useCallback(node => {
14    if (loading) return;
15
16    // Disconnect previous observer
17    if (observer.current) observer.current.disconnect();
18
19    observer.current = new IntersectionObserver(entries => {
20      if (entries[0].isIntersecting && hasMore) {
21        setPage(prev => prev + 1);
22      }
23    });
24
25    if (node) observer.current.observe(node);
26  }, [loading, hasMore]);

useCallback makes the ref callback change only together with loading or hasMore. React then detaches the old function and attaches the new one, and we disconnect the previous observer with disconnect(). Since React 19 a ref callback can also return a cleanup function. The fetching itself looks familiar:

1  useEffect(() => {
2    async function loadPage() {
3      setLoading(true);
4      const response = await fetch(
5        `https://swapi.dev/api/people/?page=${page}`
6      );
7      const data = await response.json();
8
9      setItems(prev => [...prev, ...data.results]);
10      setHasMore(data.next !== null);
11      setLoading(false);
12    }
13
14    loadPage();
15  }, [page]);
16
17  return (
18    <div>
19      {items.map((item, index) => {
20        // Attach ref to the last element
21        if (index === items.length - 1) {
22          return (
23            <div ref={lastItemRef} key={item.name}>
24              {item.name}
25            </div>
26          );
27        }
28        return <div key={item.name}>{item.name}</div>;
29      })}
30      {loading && <p>Loading more...</p>}
31      {!hasMore && <p>End of list</p>}
32    </div>
33  );
34}

When the last item of the list becomes visible, the observer increments page, the effect fetches the next page and appends it to the list. setHasMore(data.next !== null) stops the whole mechanism at the end of the data. I recommend infinite scroll for browsing, like a news feed, but for a catalog where someone wants to return to a specific spot, pages work better. In the final project you'll use exactly that: pagination with buttons.

Remember: a galaxy is scanned sector by sector, because nobody downloads the whole universe in a single request.

Code for this lesson: App.jsx
1import React, { useState, useEffect, useRef, useCallback } from 'react';
2
3function PaginatedExplorer() {
4  const [mode, setMode] = useState('pages'); // 'pages' or 'infinite'
5
6  return (
7    <div className="app">
8      <h1>Space Catalog</h1>
9      <div className="mode-toggle">
10        <button
11          className={"mode-btn" + (mode === 'pages' ? " active" : "")}
12          onClick={() => setMode('pages')}
13        >
14          Pagination
15        </button>
16        <button
17          className={"mode-btn" + (mode === 'infinite' ? " active" : "")}
18          onClick={() => setMode('infinite')}
19        >
20          Infinite Scroll
21        </button>
22      </div>
23      {mode === 'pages' ? <PagedList /> : <InfiniteList />}
24    </div>
25  );
26}
27
28// Traditional pagination
29function PagedList() {
30  const [data, setData] = useState(null);
31  const [page, setPage] = useState(1);
32  const [loading, setLoading] = useState(true);
33
34  useEffect(() => {
35    setLoading(true);
36    fetch(`https://swapi.dev/api/people/?page=${page}`)
37      .then(res => res.json())
38      .then(result => {
39        setData(result);
40        setLoading(false);
41      })
42      .catch(() => setLoading(false));
43  }, [page]);
44
45  const totalPages = data ? Math.ceil(data.count / 10) : 1;
46
47  return (
48    <div>
49      {loading ? (
50        <div className="loading"><div className="spinner"></div></div>
51      ) : data ? (
52        <>
53          <p className="count">Found: {data.count} characters</p>
54          <div className="card-list">
55            {data.results.map(person => (
56              <div key={person.name} className="card">
57                <h3>{person.name}</h3>
58                <p>Height: {person.height}cm | Weight: {person.mass}kg</p>
59                <p>Birth year: {person.birth_year}</p>
60              </div>
61            ))}
62          </div>
63          <div className="pagination">
64            <button onClick={() => setPage(p => p - 1)} disabled={page === 1}>
65              Previous
66            </button>
67            <span>Page {page} of {totalPages}</span>
68            <button onClick={() => setPage(p => p + 1)} disabled={page >= totalPages}>
69              Next
70            </button>
71          </div>
72        </>
73      ) : null}
74    </div>
75  );
76}
77
78// Infinite Scroll
79function InfiniteList() {
80  const [items, setItems] = useState([]);
81  const [page, setPage] = useState(1);
82  const [hasMore, setHasMore] = useState(true);
83  const [loading, setLoading] = useState(false);
84  const observer = useRef();
85
86  const lastItemRef = useCallback(node => {
87    if (loading) return;
88    if (observer.current) observer.current.disconnect();
89
90    observer.current = new IntersectionObserver(entries => {
91      if (entries[0].isIntersecting && hasMore) {
92        setPage(prev => prev + 1);
93      }
94    });
95
96    if (node) observer.current.observe(node);
97  }, [loading, hasMore]);
98
99  useEffect(() => {
100    setLoading(true);
101    fetch(`https://swapi.dev/api/people/?page=${page}`)
102      .then(res => res.json())
103      .then(result => {
104        setItems(prev => [...prev, ...result.results]);
105        setHasMore(result.next !== null);
106        setLoading(false);
107      })
108      .catch(() => setLoading(false));
109  }, [page]);
110
111  return (
112    <div>
113      <p className="count">Loaded: {items.length} characters</p>
114      <div className="card-list">
115        {items.map((person, index) => (
116          <div
117            key={person.name + index}
118            className="card"
119            ref={index === items.length - 1 ? lastItemRef : null}
120          >
121            <h3>{person.name}</h3>
122            <p>Height: {person.height}cm | Weight: {person.mass}kg</p>
123            <p>Birth year: {person.birth_year}</p>
124          </div>
125        ))}
126      </div>
127      {loading && <div className="loading"><div className="spinner"></div></div>}
128      {!hasMore && <p className="end">End of list - the entire galaxy has been scanned!</p>}
129    </div>
130  );
131}
132
133export default PaginatedExplorer;

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What is the main difference between offset pagination (page/limit) and cursor pagination (cursor)?

  2. 2. Which property of a paginated API response contains the URL to fetch the next page of results?

These are 2 of 3 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Horizontal ordering

    Arrange the syntax for adding new elements to existing ones in pagination (spread operator):

  • Vertical ordering

    Arrange the steps for implementing infinite scroll with IntersectionObserver in order:

  • Code editor

    Pagination with buttons

  • Code editor

    Load More Button

  • Vertical ordering

    Arrange the elements of the complete data fetching pattern in React (from start to finish):

  • Horizontal ordering

    Arrange the syntax for checking if there are more pages to fetch:

  • Click in order

    Click the elements in order to create an IntersectionObserver in React:

  • Vertical ordering

    Arrange HTTP status codes from success to most severe error:

  • Horizontal ordering

    Arrange the debouncing syntax with setTimeout in useEffect:

  • Vertical ordering

    Arrange data fetching techniques from simplest to most advanced:

Useful articles