How to implement infinite scroll using React, Hooks and Intersection Observers


When we want to add an infinite scroll to a React app, we can try to find a library that already does it for us, or we can implement our version from scratch. Since building things is one of the best ways to improve as a software developer, in this article, we'll go over how to develop an infinite scroll.

Before we start to develop something, we need to have a good grasp of what it is that we want to build. So let's elaborate a bit more on the context in which we'll implement an infinite scroll and how exactly it should behave.

Context and Requirements

We will implement an infinite scroll in the context of a simple web app, which its goal is to show images from the API Lorem Picsum. The app will have a single page that will work similarly to a feed from a social networking app. But instead of showing posts, it will show images.

The requirements for the infinite scroll app are:

  1. Shows images from Lorem Picsum API;
  2. While fetching images, the app should show a loading message;
  3. If there's an error fetching images, the app should allow the user to retry fetching;
  4. Whenever the user has seen all the images on his screen, the app should request new ones;
  5. If the user has consumed all the content from the API, the app should show him a message saying so;
  6. After the user has seen all the images from the API, the app shouldn't make more API requests;
  7. While the app is making an API request, there shouldn't be any more API requests.

Note: If you're curious about how the app will look like, you can take a look at this demo.

States, actions, and transitions

A useful way to structure UIs that have some complexity is as a state machine. For our app, the following states come right up to mind:

  1. Idle - The app isn't fetching images.
  2. Loading - The app is fetching new images
  3. Error - There was an error fetching images.
  4. Finished - The app has fetched all images from the API.

We'll need to have actions to transition our app from state to state. The actions that come to mind are:

  1. Start Fetch - Fetch new images.
  2. Fetch Success - Successfully fetched new images.
  3. Fetch Error - There was an error fetching new images.
  4. Reached End - The API didn't have more images.

We can represent our states and actions in code using constants:

const startFetch = "START_FETCH"
const fetchSuccess = "FETCH_SUCCESS"
const errorFetch = "FETCH_ERROR"
const reachedEnd = "REACHED_END"

const idleStatus = "IDLE"
const errorStatus = "ERROR"
const loadingStatus = "LOADING"
const finishedStatus = "FINISHED"

We also need to define how the states and actions will interact with each other. In other words, we need to set the transitions.

When the app is IDLE, the only action that we'll need to listen to is START_FETCH, and that will cause a transition to LOADING.

The state ERROR is the same as the IDLE on from the perspective of transitions. It also only listens to START_FETCH, which will cause a change to LOADING.

State LOADING listens to the actions FETCH_SUCCESS, FETCH_ERROR, and REACHED_END. On FETCH_SUCCESS it should go to IDLE. On FETCH_ERROR it should go to ERROR. And on REACHED_END it should go to FINISHED.

The state FINISHED is a terminal state. The app won't have any more state changes from there, so it doesn't need to listen to any actions.

Since we're using React and React has the useReducer hook, we'll be representing the state transitions in our code using a reducer.

function infiniteScrollReducer(state, action) {
  switch (state.status) {
    case idleStatus:
    case errorStatus:
      return action.type === startFetch
        ? { ...state, status: loadingStatus }
        : state

    case loadingStatus:
      if (action.type === errorFetch) {
        return { ...state, status: errorStatus }
      }
      if (action.type === reachedEnd) {
        return { ...state, status: finishedStatus }
      }
      if (action.type === fetchSuccess) {
        return {
          ...state,
          status: idleStatus,
        }
      }
      return state
    case finishedStatus:
      return state
    default:
      throw new Error("Unknown state")
  }
}

Fetch images request

Now that we've gone over the states and actions the app will have, let's implement the function that will fetch the images.

Given we need to have an error and a finished state, we need to define what constitutes an error, and what signals that we've reached the end.

We'll consider that there was an error if the API response status is different than 200 or if the request throws an error. We'll assume that we've consumed all images if the API returns an empty array.

Since we're fetching images to show later, we'll need to store them somewhere. The API uses a pagination system, so we'll also have to save the current page as well. Given we're already using a reducer, let's add those values to the reducer state and have the fetch function receive it.

We'll also need the fetch request to dispatch actions, so we'll give it the dispatch function as well.

async function fetchImages(state, dispatch) {
  try {
    const res = await fetch(
      `https://picsum.photos/v2/list?page=${state.pageNumber}&limit=5`
    )
    if (res.status === 200) {
      const imgs = await res.json()
      if (imgs.length === 0) {
        return dispatch({ type: reachedEnd })
      }
      return dispatch({
        type: fetchSuccess,
        payload: { imagesUrls: imgs.map(i => i.download_url) },
      })
    } else {
      dispatch({ type: errorFetch })
    }
  } catch (e) {
    dispatch({ type: errorFetch })
  }
}

To wrap up this part of our code, all we have left to do is to store the newly retrieved images and to increment the page number if the request was successful. We can handle that at the reducer.

 case loadingStatus:
      //code to handle other actions
      ...
      if (action.type === fetchSuccess) {
        return {
          ...state,
          imagesUrls: [...state.imagesUrls, ...action.payload.imagesUrls],
          pageNumber: state.pageNumber + 1,
          status: idleStatus
        };
      }

Trigger images fetch

To fulfill the requirements, we need to fetch and show new images when the user has seen all the current ones. Which pops up the question "How do we know the user has seen all the images?"

A common approach is to check the scroll bar position. If the scroll bar is at the end of the page, the user has seen all content. However, this approach takes for granted that we'll fetch enough images from the start to fill up the height of the user's screen, thus creating a scroll bar. Depending on the height of the user's screen, this might not be true.

A more robust approach is to use the browser's Intersection Observer API.

Intersection Observers allow us to know when an element is being shown on the screen. So what we'll do is have an HTML element that will be after the last image shown to the user. If that element appears on the screen, we know the user as seen all the images. Let's call that element borderBottom.

When the Intersection Observer notifies us that borderBottom is showing, we want to fetch new images, so we should dispatch START_FETCH.

To use an Intersection Observer on a React component, we need to have the borderBottom HTML element. To do that we'll use a callback ref. After React creates the HTML element of borderBottom, it will call the callback ref, and we'll check for when borderBottom becomes visible to the user through an Intersection Observer. This approach results in the following:

const observeBorder = useCallback(node => {
    if (node !== null) {
      new IntersectionObserver(
        entries => {
          entries.forEach(en => {
            if (en.intersectionRatio === 1) {
              dispatch({ type: startFetch });
            }
          });
        },
        { threshold: 1 }
      ).observe(node);
    }
  }, []);

function renderBottomBorder() {
    return <div ref={observeBorder} />;
  }

The only thing that's left to do is the actual API call.

useEffect(() => {
  if (state.status === loadingStatus) {
    fetchImages(state, dispatch)
  }
}, [state])

By giving state as a dependency to useEffect hook, we get the possibility to execute a function when entering a new state. For our app, we only want to execute a function when entering LOADING. So we check that we're now loading and fetch new images. We must be careful not to change the identity of the object state, unless we're changing the state of the app. Otherwise, we'll be triggering the useEffect callback. Which, in our case, might result in doing API calls while the app is loading. This is especially easy to happen when implementing the reducer. All it takes is to return {...state} instead of state on default cases.

Views

We're almost done. All that's left is to render the images, messages, and the error button when the state is appropriate. Also, remember that the user should be able to click the error button to fetch the images again. So we'll have to dispatch an action when the user clicks the button.

return (
    <>
      {renderImages()}
      {state.status === errorStatus && renderErrorRetryButton()}
      {state.status === loadingStatus && renderLoadingMessage()}
      {state.status === finishedStatus && renderNoMoreImagesMessage()}
      {renderBottomBorder()}
    </>
  );

  function renderBottomBorder() {
    return <div ref={observeBorder} />;
  }

  function renderNoMoreImagesMessage() {
    return <p>There aren't more images</p>;
  }

  function renderImages() {
    return state.imagesUrls.map(url => (
      <img key={url} style={imageStyle} src={url} alt="mock alt" />
    ));
  }

  function renderErrorRetryButton() {
    return (
      <button type="button" onClick={() => dispatch({ type: startFetch })}>
        Error! Click to try again
      </button>
    );
  }

  function renderLoadingMessage() {
    return <p>Loading...</p>;
  }

End result

We've implemented all the pieces of the app. Let's take a look at the final result.

const startFetch = "START_FETCH";
const fetchSuccess = "FETCH_SUCCESS";
const errorFetch = "FETCH_ERROR";
const reachedEnd = "REACHED_END";

const idleStatus = "IDLE";
const errorStatus = "ERROR";
const loadingStatus = "LOADING";
const finishedStatus = "FINISHED";

function infiniteScrollReducer(state, action) {
  switch (state.status) {
    case idleStatus:
    case errorStatus:
      return action.type === startFetch
        ? { ...state, status: loadingStatus }
        : state;

    case loadingStatus:
      if (action.type === errorFetch) {
        return { ...state, status: errorStatus };
      }
      if (action.type === reachedEnd) {
        return { ...state, status: finishedStatus };
      }
      if (action.type === fetchSuccess) {
        return {
          ...state,
          imagesUrls: [...state.imagesUrls, ...action.payload.imagesUrls],
          pageNumber: state.pageNumber + 1,
          status: idleStatus
        };
      }
      return state;
    case finishedStatus:
      return state;
    default:
      throw new Error("Unknown state");
  }
}

async function fetchImages(state, dispatch) {
  try {
    const res = await fetch(
      `https://picsum.photos/v2/list?page=${state.pageNumber}&limit=5`
    );
    if (res.status === 200) {
      const imgs = await res.json();
      if (imgs.length === 0) {
        return dispatch({ type: reachedEnd });
      }
      return dispatch({
        type: fetchSuccess,
        payload: { imagesUrls: imgs.map(i => i.download_url) }
      });
    } else {
      dispatch({ type: errorFetch });
    }
  } catch (e) {
    dispatch({ type: errorFetch });
  }
}

const initialState = { imagesUrls: [], status: idleStatus, pageNumber: 1 };

function InfiniteScroll() {
  const [state, dispatch] = useReducer(infiniteScrollReducer, initialState);
  useEffect(() => {
    if (state.status === loadingStatus) {
      fetchImages(state, dispatch);
    }
  }, [state]);

  const observeBorder = useCallback(node => {
    if (node !== null) {
      new IntersectionObserver(
        entries => {
          entries.forEach(en => {
            if (en.intersectionRatio === 1) {
              dispatch({ type: startFetch });
            }
          });
        },
        { threshold: 1 }
      ).observe(node);
    }
  }, []);

  return (
    <>
      {renderImages()}
      {state.status === errorStatus && renderErrorRetryButton()}
      {state.status === loadingStatus && renderLoadingMessage()}
      {state.status === finishedStatus && renderNoMoreImagesMessage()}
      {renderBottomBorder()}
    </>
  );

  function renderBottomBorder() {
    return <div ref={observeBorder} />;
  }

  function renderNoMoreImagesMessage() {
    return <p>There aren't more images</p>;
  }

  function renderImages() {
    return state.imagesUrls.map(url => (
      <img key={url} style={imageStyle} src={url} alt="mock alt" />
    ));
  }

  function renderErrorRetryButton() {
    return (
      <button type="button" onClick={() => dispatch({ type: startFetch })}>
        Error! Click to try again
      </button>
    );
  }

  function renderLoadingMessage() {
    return <p>Loading...</p>;
  }
}

const imageStyle = {
  width: "300px",
  height: "200px",
  display: "block",
  marginBottom: "20px"
};

Considerations

Since we're dealing with images in the context of our app, we could have done some further optimizations. Lazy loading images and making them responsive being two that come right up to my mind. But given the focus of this article is on implementing infinite scroll, I left that part out of the scope.

Another vital thing that I didn't go over in this article is how to test this implementation. The way I prefer to do it is actually to start out by writing tests and then the implementation. However, I couldn't manage to mirror that process in this article while keeping it at a reasonable size and understandable. Instead, I opted to leave a link to a Repository where you can find this app and the tests to it. In the future, I might write an article on how to test/test drive this app.

I hope that this approach of defining the requirements, followed by designing architecture and then going over to implementation, made sense to you. It's an approach that has proved useful to me time and time again.

If you enjoyed this article, you might also like this one about how to perform a search when a user stops typing using React and Hooks.