Implementing Infinite Scroll And Image Lazy Loading In React
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. Let’s get started!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.
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.
- The
STACK_IMAGES
action concatenates theimages
array. FETCHING_IMAGES
action toggles the value of thefetching
variable betweentrue
andfalse
.
The next step is to wire up this reducer to a useReducer
hook. Once that is done, we get back two things:
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.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;
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 → callADVANCE_PAGE
action → increment value ofpager.page
by 1 →useEffect
hook for fetch call runs →fetch
call is run → returned images are concatenated to theimages
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.
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
- “Infinite Scroll + Image Lazy Loading,” Orji Chidi Matthew, GitHub
- “Infinite Scrolling, Pagination Or “Load More” Buttons? Usability Findings In eCommerce,” Christian Holst, Smashing Magazine
- “Lorem Picsum,” David Marby & Nijiko Yonskai
- “IntersectionObserver’s Coming Into View,” Surma, Web Fundamentals
- Can I Use…
IntersectionObserver
- “Intersection Observer API,” MDN web docs
- “Components And Props,” React
- “
useCallback
,” React - “
useReducer
,” React
Further Reading
- Uniting Web And Native Apps With 4 Unknown JavaScript APIs
- The Forensics Of React Server Components (RSCs)
- Scaling Success: Key Insights And Practical Takeaways
- How To Hack Your Google Lighthouse Scores In 2024