3 min read

Tutorial: Build an infinite scrolling list of Pokémon

💡
This is a tutorial that solves the "Build an infinite scrolling list of pokemons" exercise.
If you haven't already, try to solve it yourself first before reading the solution!

Let's start by displaying the first 12 Pokémon, with their name and image.
This is as simple as using an useEffect for the initial load.

import { useState } from "react";
import { useEffect } from "react";

const PAGE_SIZE = 12;

const fetchPokemonPage = async (offset = 0) => {
  const response = await fetch(
    `https://pokeapi.co/api/v2/pokemon?limit=${PAGE_SIZE}&offset=${offset}`
  );
  const data = await response.json();
  return data.results;
};

const PokemonsList = () => {
  const [pokemons, setPokemons] = useState([]);
  const [isPending, setIsPending] = useState(false);
  useEffect(() => {
    setIsPending(true);
    fetchPokemonPage().then((firstPageOfPokemons) => {
      setPokemons(firstPageOfPokemons);
      setIsPending(false);
    });
  }, []);
  return (
    <div>
      <div
        style={{
          display: "grid",
          gridTemplateColumns: "250px 250px 250px 250px",
          margin: "auto",
          maxWidth: "1000px",
        }}
      >
        {pokemons.map((pokemon) => (
          <div
            key={pokemon.name}
            style={{
              border: "1px solid lightgray",
              padding: "5px",
              margin: "5px",
              textAlign: "center",
            }}
          >
            <h3>{pokemon.name}</h3>
            <img
              src={`https://img.pokemondb.net/artwork/${pokemon.name}.jpg`}
              width="200px"
            />
          </div>
        ))}
      </div>
      {isPending && (
        <div style={{ textAlign: "center", margin: "10px" }}>Loading ...</div>
      )}
    </div>
  );
};

export default PokemonsList;


Loading the next page on scroll

To detect when the user has reached the end of the page, we can use the Intersection Observer API. (If you haven't read the docs about this, I highly recommend you do).

We will need:

  • an element that represents the "end of the page" - we can just add an empty div right after the list of pokemons
  • an intersection observer that can track when the "end of page" intersects the viewport

First, let's add the empty div. Since we will need to reference it when creating the observer, we can save it in a ref:

const endOfPageRef = useRef();
...
return (
  <div>
      ...
     <div ref={endOfPageRef}></div>
  </div>
);

Next, let's create the observer. Since this is a side effect and we only want to create it on component mount, we add it to an useEffect with an empty dependency array:

useEffect(() => {
    // create the observer
    const observer = new IntersectionObserver((entries) => {
      // entries contains one entry for each "watched" element
      // in our case, we only have one - the `endOfPage` div
      const endOfPage = entries[0];
      if (endOfPage.isIntersecting) {
        console.log("is intersecting");
      } else {
        console.log("is not intersecting");
      }
    });
    // watch for changes to the intersection 
    // between `endOfPage` and the viewport
    observer.observe(endOfPageRef.current);
  }, []);

Trying this out in the app works as expected - we can see the console log "is intersecting" every time we scroll to the end of the page and "not intersecting" when we scroll back up.

There's only catch - the code appears to show "is intersecting" also before we scroll at all! This is because when we register the observer with observer.observe, the callback gets called to check the overlap status on initialisation. Since the initial pokemons list did not get a change to load, the "end of page" div is visible, so the elements appear as intersecting!

To work around this, we can check not only if the endOfPage is intersecting, but also if the page isn't in a loading state:

if (endOfPage.isIntersecting && !isPending) {
	console.log("is intersecting");
  } else {
	console.log("is not intersecting");
  }

If we add this, we will also need to add isPending to the dependencies array of useEffect:

useEffect(() => {
    const observer = new IntersectionObserver((entries) => {      
      const endOfPage = entries[0];
      if (endOfPage.isIntersecting && !isPending) {
        console.log("is intersecting");
      } else {
        console.log("is not intersecting");
      }
    });
    observer.observe(endOfPageRef.current);
  }, [isPending]);

But this leads to another issue - our observer is now created and recreated every time isPending changes and we don't want that - we just want to register it once, on component mount.

What we can do instead is use a ref for the intersection callback, which we can point to a handler that gets updated on every render. This is a technique that Dan Abramov introduced for using setInterval with React. You can read more about this in the article on using Intersection Observer API with React.

const PokemonsList = () => {
  ...
  const endOfPageRef = useRef();
  const intersectionCallback = useRef();

  useEffect(() => {
    const observer = new IntersectionObserver((entries) =>
      intersectionCallback.current(entries)
    );
    observer.observe(endOfPageRef.current);
  }, []);

  const handleIntersection = (entries) => {
    const endOfPage = entries[0];
    if (endOfPage.isIntersecting && !isPending) {
      console.log("is intersecting");
    } else {
      console.log("is not intersecting");
    }
  };

  useEffect(() => {
    intersectionCallback.current = handleIntersection;
  });

  return (
      ...
      {isPending && (
        <div style={{ textAlign: "center", margin: "10px" }}>Loading ...</div>
      )}
      <div ref={endOfPageRef}></div>
    </div>
  );
};

Now that we have correctly identified when the user reached the end of the page, we can fetch the next set of pokemons and display them.

  const handleIntersection = (entries) => {
    const endOfPage = entries[0];
    if (endOfPage.isIntersecting && !isPending) {
      setIsPending(true);
      fetchPokemonPage(pokemons.length).then((newPageOfPokemons) => {
        setPokemons([...pokemons, ...newPageOfPokemons]);
        setIsPending(false);
      });
    }
  };

Note we don't need to use the function version of setPokemons, since handleIntersection is created new after every render, so its closure always captures the latest state values.

And that's it! Checkout the full working code on Github.

How did you implement this exercise? Share your thoughts in the comments below.