Implementing Infinite Scroll And Image Lazy Loading In React

About The Author

Awesome frontend developer who loves everything coding. I’m a lover of choral music and I’m working to make it more accessible to the world, one upload at a … More about Chidi ↬

Email Newsletter

Weekly tips on front-end & UX.
Trusted by 200,000+ folks.

In this tutorial, we’re going to learn how to use the HTML Intersection Observer API to implement infinite scrolling and image lazy loading in a React functional component. In the process, we’ll learn how to use some of React’s hooks and how to create Custom Hooks.

If you have been looking for an alternative to pagination, infinite scroll is a good consideration. In this article, we’re going to explore some use cases for the Intersection Observer API in the context of a React functional component. The reader should possess a working knowledge of React functional components. Some familiarity with React hooks will be beneficial but not required, as we will be taking a look at a few.

Our goal is that at the end of this article, we will have implemented infinite scroll and image lazy loading using a native HTML API. We would also have learned a few more things about React Hooks. With that you can be able to implement infinite scroll and image lazy loading in your React application where necessary.

Let’s get started.

Creating Maps With React And Leaflet

Grasping information from a CSV or a JSON file isn’t only complicated, but is also tedious. Representing the same data in the form of visual aid is simpler. Shajia Abidi explains how powerful of a tool Leaflet is, and how a lot of different kinds of maps can be created. Read a related article →

The Intersection Observer API

According to the MDN docs, “the Intersection Observer API provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with a top-level document’s viewport”.

This API allows us to implement cool features such as infinite scroll and image lazy loading. The intersection observer is created by calling its constructor and passing it a callback and an options object. The callback is invoked whenever one element, called the target, intersects either the device viewport or a specified element, called the root. We can specify a custom root in the options argument or use the default value.

let observer = new IntersectionObserver(callback, options);

The API is straightforward to use. A typical example looks like this:

var intObserver = new IntersectionObserver(entries => {
    entries.forEach(entry => {
      console.log(entry)
      console.log(entry.isIntersecting) // returns true if the target intersects the root element
    })
  },
  {
    // default options
  }
);
let target = document.querySelector('#targetId');
intObserver.observe(target); // start observation

entries is a list of IntersectionObserverEntry objects. The IntersectionObserverEntry object describes an intersection change for one observed target element. Note that the callback should not handle any time-consuming task as it runs on the main thread.

The Intersection Observer API currently enjoys broad browser support, as shown on caniuse.

Intersection Observer browser support. (Large preview)

You can read more about the API in the links provided in the resources section.

Let us now look at how to make use of this API in a real React app. The final version of our app will be a page of pictures that scrolls infinitely and will have each image loaded lazily.

Making API Calls With The useEffect Hook

To get started, clone the starter project from this URL. It has minimal setup and a few styles defined. I’ve also added a link to Bootstrap’s CSS in the public/index.html file as I’ll be using its classes for styling.

Feel free to create a new project if you like. Make sure you have yarn package manager installed if you want to follow with the repo. You can find the installation instructions for your specific operating system here.

For this tutorial, we’re going to be grabbing pictures from a public API and displaying them on the page. We will be using the Lorem Picsum APIs.

For this tutorial, we’ll be using the endpoint, https://picsum.photos/v2/list?page=0&limit=10, which returns an array of picture objects. To get the next ten pictures, we change the value of page to 1, then 2, and so on.

We will now build the App component piece by piece.

Open up src/App.js and enter the following code.

import React, { useEffect, useReducer } from 'react';

import './index.css';

function App() {
  const imgReducer = (state, action) => {
    switch (action.type) {
      case 'STACK_IMAGES':
        return { ...state, images: state.images.concat(action.images) }
      case 'FETCHING_IMAGES':
        return { ...state, fetching: action.fetching }
      default:
        return state;
    }
  }
  const [imgData, imgDispatch] = useReducer(imgReducer,{ images:[], fetching: true})
  // next code block goes here
}

Firstly, we define a reducer function, imgReducer. This reducer handles two actions.

  1. The STACK_IMAGES action concatenates the images array.
  2. FETCHING_IMAGES action toggles the value of the fetching variable between true and false.

The next step is to wire up this reducer to a useReducer hook. Once that is done, we get back two things:

  1. imgData, which contains two variables: images is the array of picture objects. fetching is a boolean which tells us if the API call is in progress or not.
  2. imgDispatch, which is a function for updating the reducer object.

You can learn more about the useReducer hook in the React documentation.

The next part of the code is where we make the API call. Paste the following code below the previous code block in App.js.

// make API calls
useEffect(() => {
  imgDispatch({ type: 'FETCHING_IMAGES', fetching: true })
  fetch('https://picsum.photos/v2/list?page=0&limit=10')
    .then(data => data.json())
    .then(images => {
      imgDispatch({ type: 'STACK_IMAGES', images })
      imgDispatch({ type: 'FETCHING_IMAGES', fetching: false })
    })
    .catch(e => {
      // handle error
      imgDispatch({ type: 'FETCHING_IMAGES', fetching: false })
      return e
    })
}, [ imgDispatch ])

// next code block goes here

Inside the useEffect hook, we make a call to the API endpoint with fetch API. We then update the images array with the result of the API call by dispatching the STACK_IMAGES action. We also dispatch the FETCHING_IMAGES action once the API call completes.

The next block of code defines the return value of the function. Enter the following code after the useEffect hook.

return (
  <div className="">
    <nav className="navbar bg-light">
      <div className="container">
        <a className="navbar-brand" href="/#">
          <h2>Infinite scroll + image lazy loading</h2>
        </a>
      </div>
    </navv
    <div id='images' className="container">
      <div className="row">
        {imgData.images.map((image, index) => {
          const { author, download_url } = image
          return (
            <div key={index} className="card">
              <div className="card-body ">
                <img
                  alt={author}
                  className="card-img-top"
                  src={download_url}
                />
              </div>
              <div className="card-footer">
                <p className="card-text text-center text-capitalize text-primary">Shot by: {author}</p>
              </div>
            </div>
          )
        })}
      </div>
    </div>
  </div>
);

To display the images, we map over the images array in the imgData object.

Now start the app and view the page in the browser. You should see the images nicely displayed in a responsive grid.

The last bit is to export the App component.

export default App;
Pictures in responsive grid. (Large preview)

The corresponding branch at this point is 01-make-api-calls.

Let’s now extend this by displaying more pictures as the page scrolls.

Implementing Infinite Scroll

We aim to present more pictures as the page scrolls. From the URL of the API endpoint, https://picsum.photos/v2/list?page=0&limit=10, we know that to get a new set of photos, we only need to increment the value of page. We also need to do this when we have run out of pictures to show. For our purpose here, we’ll know we have run out of images when we hit the bottom of the page. It’s time to see how the Intersection Observer API helps us achieve that.

Open up src/App.js and create a new reducer, pageReducer, below imgReducer.

// App.js
const imgReducer = (state, action) => {
  ...
}
const pageReducer = (state, action) => {
  switch (action.type) {
    case 'ADVANCE_PAGE':
      return { ...state, page: state.page + 1 }
    default:
      return state;
  }
}
const [ pager, pagerDispatch ] = useReducer(pageReducer, { page: 0 })

We define only one action type. Each time the ADVANCE_PAGE action is triggered, the value of page is incremented by 1.

Update the URL in the fetch function to accept page numbers dynamically as shown below.

fetch(`https://picsum.photos/v2/list?page=${pager.page}&limit=10`)

Add pager.page to the dependency array alongside imgData. Doing this ensures that the API call will run whenever pager.page changes.

useEffect(() => {
...
}, [ imgDispatch, pager.page ])

After the useEffect hook for the API call, enter the below code. Update your import line as well.

// App.js
import React, { useEffect, useReducer, useCallback, useRef } from 'react';
useEffect(() => {
  ...
}, [ imgDispatch, pager.page ])

// implement infinite scrolling with intersection observer
let bottomBoundaryRef = useRef(null);
const scrollObserver = useCallback(
  node => {
    new IntersectionObserver(entries => {
      entries.forEach(en => {
        if (en.intersectionRatio > 0) {
          pagerDispatch({ type: 'ADVANCE_PAGE' });
        }
      });
    }).observe(node);
  },
  [pagerDispatch]
);
useEffect(() => {
  if (bottomBoundaryRef.current) {
    scrollObserver(bottomBoundaryRef.current);
  }
}, [scrollObserver, bottomBoundaryRef]);

We define a variable bottomBoundaryRef and set its value to useRef(null). useRef lets variables preserve their values across component renders, i.e. the current value of the variable persists when the containing component re-renders. The only way to change its value is by re-assigning the .current property on that variable.

In our case, bottomBoundaryRef.current starts with a value of null. As the page rendering cycle proceeds, we set its current property to be the node <div id='page-bottom-boundary'>.

We use the assignment statement ref={bottomBoundaryRef} to tell React to set bottomBoundaryRef.current to be the div where this assignment is declared.

Thus,

bottomBoundaryRef.current = null

at the end of the rendering cycle, becomes:

bottomBoundaryRef.current = <div id="page-bottom-boundary" style="border: 1px solid red;"></div>

We shall see where this assignment is done in a minute.

Next, we define a scrollObserver function, in which to set the observer. This function accepts a DOM node to observe. The main point to note here is that whenever we hit the intersection under observation, we dispatch the ADVANCE_PAGE action. The effect is to increment the value of pager.page by 1. Once this happens, the useEffect hook that has it as a dependency is re-run. This re-run, in turn, invokes the fetch call with the new page number.

The event procession looks like this.

Hit intersection under observation → call ADVANCE_PAGE action → increment value of pager.page by 1 → useEffect hook for fetch call runs → fetch call is run → returned images are concatenated to the images array.

We invoke scrollObserver in a useEffect hook so that the function will run only when any of the hook’s dependencies change. If we didn’t call the function inside a useEffect hook, the function would run on every page render.

Recall that bottomBoundaryRef.current refers to <div id="page-bottom-boundary" style="border: 1px solid red;"></div>. We check that its value is not null before passing it to scrollObserver. Otherwise, the IntersectionObserver constructor would return an error.

Because we used scrollObserver in a useEffect hook, we have to wrap it in a useCallback hook to prevent un-ending component re-renders. You can learn more about useCallback in the React docs.

Enter the below code after the <div id='images'> div.

// App.js
<div id='image'>
...
</div>
{imgData.fetching && (
  <div className="text-center bg-secondary m-auto p-3">
    <p className="m-0 text-white">Getting images</p>
  </div>
)}
<div id='page-bottom-boundary' style={{ border: '1px solid red' }} ref={bottomBoundaryRef}></div>

When the API call starts, we set fetching to true, and the text Getting images becomes visible. As soon as it finishes, we set fetching to false, and the text gets hidden. We could also trigger the API call before hitting the boundary exactly by setting a different threshold in the constructor options object. The red line at the end lets us see exactly when we hit the page boundary.

The corresponding branch at this point is 02-infinite-scroll.

We will now implement image lazy loading.

Implementing Image Lazy Loading

If you inspect the network tab as you scroll down, you’ll see that as soon as you hit the red line (the bottom boundary), the API call happens, and all the images start loading even when you haven’t gotten to viewing them. There are a variety of reasons why this might not be desirable behavior. We may want to save network calls until the user wants to see an image. In such a case, we could opt for loading the images lazily, i.e., we won’t load an image until it scrolls into view.

Open up src/App.js. Just below the infinite scrolling functions, enter the following code.

// App.js

// lazy loads images with intersection observer
// only swap out the image source if the new url exists
const imagesRef = useRef(null);
const imgObserver = useCallback(node => {
  const intObs = new IntersectionObserver(entries => {
    entries.forEach(en => {
      if (en.intersectionRatio > 0) {
        const currentImg = en.target;
        const newImgSrc = currentImg.dataset.src;
        // only swap out the image source if the new url exists
        if (!newImgSrc) {
          console.error('Image source is invalid');
        } else {
          currentImg.src = newImgSrc;
        }
        intObs.unobserve(node); // detach the observer when done
      }
    });
  })
  intObs.observe(node);
}, []);
useEffect(() => {
  imagesRef.current = document.querySelectorAll('.card-img-top');
  if (imagesRef.current) {
    imagesRef.current.forEach(img => imgObserver(img));
  }
}, [imgObserver, imagesRef, imgData.images]);

As with scrollObserver, we define a function, imgObserver, which accepts a node to observe. When the page hits an intersection, as determined by en.intersectionRatio > 0, we swap the image source on the element. Notice that we first check if the new image source exists before doing the swap. As with the scrollObserver function, we wrap imgObserver in a useCallback hook to prevent un-ending component re-render.

Also note that we stop observing an img element once we’re done with the substitution. We do this with the unobserve method.

In the following useEffect hook, we grab all the images with a class of .card-img-top on the page with document.querySelectorAll. Then we iterate over each image and set an observer on it.

Note that we added imgData.images as a dependency of the useEffect hook. When this changes it triggers the useEffect hook and in turn imgObserver get called with each <img className='card-img-top'> element.

Update the <img className='card-img-top'/> element as shown below.

<img
  alt={author}
  data-src={download_url}
  className="card-img-top"
  src={'https://picsum.photos/id/870/300/300?grayscale&blur=2'}
/>

We set a default source for every <img className='card-img-top'/> element and store the image we want to show on the data-src property. The default image usually has a small size so that we’re downloading as little as possible. When the <img/> element comes into view, the value on the data-src property replaces the default image.

In the picture below, we see the default lighthouse image still showing in some of the spaces.

Images being lazily loaded. (Large preview)

The corresponding branch at this point is 03-lazy-loading.

Let’s now see how we can abstract all these functions so that they’re re-usable.

Abstracting Fetch, Infinite Scroll And Lazy Loading Into Custom Hooks

We have successfully implemented fetch, infinite scroll, and image lazy loading. We might have another component in our application that needs similar functionality. In that case, we could abstract and reuse these functions. All we have to do is move them inside a separate file and import them where we need them. We want to turn them into Custom Hooks.

The React documentation defines a Custom Hook as a JavaScript function whose name starts with "use" and that may call other hooks. In our case, we want to create three hooks, useFetch, useInfiniteScroll, useLazyLoading.

Create a file inside the src/ folder. Name it customHooks.js and paste the code below inside.

// customHooks.js

import { useEffect, useCallback, useRef } from 'react';
// make API calls and pass the returned data via dispatch
export const useFetch = (data, dispatch) => {
  useEffect(() => {
    dispatch({ type: 'FETCHING_IMAGES', fetching: true });
    fetch(`https://picsum.photos/v2/list?page=${data.page}&limit=10`)
      .then(data => data.json())
      .then(images => {
        dispatch({ type: 'STACK_IMAGES', images });
        dispatch({ type: 'FETCHING_IMAGES', fetching: false });
      })
      .catch(e => {
        dispatch({ type: 'FETCHING_IMAGES', fetching: false });
        return e;
      })
  }, [dispatch, data.page])
}

// next code block here

The useFetch hook accepts a dispatch function and a data object. The dispatch function passes the data from the API call to the App component, while the data object lets us update the API endpoint URL.

// infinite scrolling with intersection observer
export const useInfiniteScroll = (scrollRef, dispatch) => {
  const scrollObserver = useCallback(
    node => {
      new IntersectionObserver(entries => {
        entries.forEach(en => {
          if (en.intersectionRatio > 0) {
            dispatch({ type: 'ADVANCE_PAGE' });
          }
        });
      }).observe(node);
    },
    [dispatch]
  );
  useEffect(() => {
    if (scrollRef.current) {
      scrollObserver(scrollRef.current);
    }
  }, [scrollObserver, scrollRef]);
}

// next code block here

The useInfiniteScroll hook accepts a scrollRef and a dispatch function. The scrollRef helps us set up the observer, as already discussed in the section where we implemented it. The dispatch function gives a way to trigger an action that updates the page number in the API endpoint URL.

// lazy load images with intersection observer
export const useLazyLoading = (imgSelector, items) => {
  const imgObserver = useCallback(node => {
  const intObs = new IntersectionObserver(entries => {
    entries.forEach(en => {
      if (en.intersectionRatio > 0) {
        const currentImg = en.target;
        const newImgSrc = currentImg.dataset.src;
        // only swap out the image source if the new url exists
        if (!newImgSrc) {
          console.error('Image source is invalid');
        } else {
          currentImg.src = newImgSrc;
        }
        intObs.unobserve(node); // detach the observer when done
      }
    });
  })
  intObs.observe(node);
  }, []);
  const imagesRef = useRef(null);
  useEffect(() => {
    imagesRef.current = document.querySelectorAll(imgSelector);
    if (imagesRef.current) {
      imagesRef.current.forEach(img => imgObserver(img));
    }
  }, [imgObserver, imagesRef, imgSelector, items])
}

The useLazyLoading hook receives a selector and an array. The selector is used to find the images. Any change in the array triggers the useEffect hook that sets up the observer on each image.

We can see that it is the same functions we have in src/App.js that we have extracted to a new file. The good thing now is that we can pass arguments dynamically. Let’s now use these custom hooks in the App component.

Open src/App.js. Import the custom hooks and delete the functions we defined for fetching data, infinite scroll, and image lazy loading. Leave the reducers and the sections where we make use of useReducer. Paste in the below code.

// App.js

// import custom hooks
import { useFetch, useInfiniteScroll, useLazyLoading } from './customHooks'

  const imgReducer = (state, action) => { ... } // retain this
  const pageReducer = (state, action) => { ... } // retain this
  const [pager, pagerDispatch] = useReducer(pageReducer, { page: 0 }) // retain this
  const [imgData, imgDispatch] = useReducer(imgReducer,{ images:[], fetching: true }) // retain this

let bottomBoundaryRef = useRef(null);
useFetch(pager, imgDispatch);
useLazyLoading('.card-img-top', imgData.images)
useInfiniteScroll(bottomBoundaryRef, pagerDispatch);

// retain the return block
return (
  ...
)

We have already talked about bottomBoundaryRef in the section on infinite scroll. We pass the pager object and the imgDispatch function to useFetch. useLazyLoading accepts the class name .card-img-top. Note the . included in the class name. By doing this, we don’t need to specify it document.querySelectorAll. useInfiniteScroll accepts both a ref and the dispatch function for incrementing the value of page.

The corresponding branch at this point is 04-custom-hooks.

Conclusion

HTML is getting better at providing nice APIs for implementing cool features. In this post, we’ve seen how easy it is to use the intersection observer in a React functional component. In the process, we learned how to use some of React’s hooks and how to write our own hooks.

Resources

Smashing Editorial (ks, ra, yk, il)