Knowing what is visible and not visible on a webpage can be very useful information. You can lazy-load images when they come into view, stop videos when they go out of view, even get proper analytics regarding how many content users read on your blog. However, this is usually a difficult thing to implement. Historically, there was no dedicated API for this and one had to find some other means (e.g. Element.getBoundingClientRect()
) for workarounds which can negatively affect the performance of our applications.
Introducing: Intersection Observer API
A better performant way to achieve our goal. The Intersection Observer API is a browser API that can be used to track the position of HTML elements in context to the actual viewport of the browser. The official documentation says: "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." — MDN
I wanted to explore how I could implement infinite scroll with react using Intersection Observer. I thought I should summarize what I have learned to hopefully help you avoid the same mistakes I ran into.
It is important that you are comfortable using React's ref API because it is applied to enable the connection between DOM nodes and the intersection observer in React. Otherwise React is a declarative view layer library where it is not planned to access DOM nodes.
How Does The Intersection Observer API Work?
In order to get a complete understanding of the Intersection Observer API, I would recommend that you check out the documentation found at MDN.
Intersection Observers work in two parts: an observer instance attached to either a specific node or to the overall viewport and a request to this observer to monitor specific children within its descendants. When the observer is created, it is also provided with a callback that receives one or more intersection entries.
Simply put, you need to create an Observer that will ‘observe’ a DOM node and execute a callback when one or more of its threshold options are met. A threshold can be any ratio from 0 to 1 where 1 means the element is 100% in the viewport and 0 is 100% out of the viewport. By default, the threshold is set to 0.
// Example from MDN
let options = {
root: document.querySelector('#scrollArea') || null, // page as root
rootMargin: '0px',
threshold: 1.0
}
let observer = new IntersectionObserver(callback, options);
/*
options let you control the circumstances under which
the observer's callback is invoked
*/
Once you have created your observer, you have to give it a target element to watch:
let target = document.querySelector('#listItem');
observer.observe(target);
Whenever the target meets a threshold specified for the IntersectionObserver
, the callback is invoked. The callback receives a list of IntersectionObserverEntry
objects and the observer:
let callback = (entries, observer) => {
entries.forEach(entry => {
// Each entry describes an intersection change for one observed
// target element:
// entry.boundingClientRect
// entry.intersectionRatio
// entry.intersectionRect
// entry.isIntersecting
// entry.rootBounds
// entry.target
// entry.time
});
console.log(entries, observer)
};
The Threshold
The threshold refers to how much of an intersection has been observed in relation to the root of the IntersectionObserver
Let's consider this image below:
The first thing to do is to declare the page/scroll area as our root
. We can then consider the image container as our target. Scrolling the target into the root gives us different thresholds. The threshold can either be a single item, like 0.2, or an array of thresholds, like [0.1, 0.2, 0.3, ...]. It is important to note that the root property must be an ancestor to the element being observed and is the browser viewport by default.
let options = {
root: document.querySelector('#scrollArea'),
rootMargin: '0px',
threshold: [0.98, 0.99, 1]
}
let observer = new IntersectionObserver(callback, options);
We have the observer, but it’s not yet observing anything. To start it observing, you need to pass a dom node to the observe method. It can observe any number of nodes, but you can only pass in one at a time. When you no longer want it to observe a node, you call the unobserve() method and pass it the node that you would like it to stop watching or you can call the disconnect() method to stop it from observing any node, like this:
let target = document.querySelector('#listItem');
observer.observe(target);
observer.unobserve(target);
//observing only target
observer.disconnect();
//not observing any node
React
We are going to be implementing Intersection observer by creating an infinite scroll for a list of images. We will be making use of the super easy . It's a great choice because it is paginated.
NB: You should know how to fetch data using hooks, if you are not familiar, you can check out this article. Good stuff there!
import React, { useState, useEffect, useCallback } from 'react';
import axios from 'axios';
export default function App() {
const [loading, setLoading] = useState(false);
const [images, setImages] = useState([]);
const [page, setPage] = useState(1);
const fetchData = useCallback(async pageNumber => {
const url = `https://picsum.photos/v2/list?page=${page}&limit=15`;
setLoading(true);
try {
const res = await axios.get(url);
const { status, data } = res;
setLoading(false);
return { status, data };
} catch (e) {
setLoading(false);
return e;
}
}, []);
const handleInitial = useCallback(async page => {
const newImages = await fetchData(page);
const { status, data } = newImages;
if (status === 200) setImages(images => [...images, ...data]);
},
[fetchData]
);
useEffect(() => {
handleInitial(page);
}, [handleInitial]);
return (
<div className="appStyle">
{images && (
<ul className="imageGrid">
{images.map((image, index) => (
<li key={index} className="imageContainer">
<img src={image.download_url} alt={image.author} className="imageStyle" />
</li>
))}
</ul>
)}
{loading && <li>Loading ...</li>}
<div className="buttonContainer">
<button className="buttonStyle">Load More</button>
</div>
</div>
)
}
This is the core of the app. We want to be able to load up the page and have it make API call to the Lorem Picsum API and then display some images.
This is a good first step as we have been able to handle data fetching. The next thing to do is thinking of how we can write code to make more requests and update the image lists we have stored in our state. To do this, we have to create a function that will take in the current page and then increase it by 1
. This should then trigger the useEffect()
to make a call for us and update the UI.
// const [page, setPage] = useState(1);
const loadMore = () => {
setPage(page => page + 1);
handleInitial(page);
};
Great, we have written our updater function. We can attach this to a button on the screen and have it make the calls for us!
<div className="buttonContainer">
<button className="buttonStyle" onClick={loadMore}>Load More</button>
</div>
Open up your network tab to be sure that this is working. If you checked properly, you would see that when we click on Load More
, it actually works. The only problem is, it is reading the updated value of page as 1
. This is interesting, you might be wondering why that is so. The simple answer is, we are still in a function scope when the update is made and we do not have access to the updated state until the function finishes executing. This is unlike setState()
where you had a callback available.
Ok, so how do we solve this. We will be making use of react useRef()
hook. useRef()
returns an object that has a current attribute pointing to the item you are referencing.
import React, { useRef } from "react";
const Game = () => {
const gameRef = useRef(1);
};
const increaseGame = () => {
gameRef.current; // this is how to access the current item
gameRef.current++;
console.log(gameRef); // 2, update made while in the function scope.
}
This approach will help us properly handle the data fetching in our application.
// Instead of const [page, setPage] = useState(1);
const page = useRef(1);
const loadMore = () => {
page.current++;
handleInitial(page);
};
useEffect(() => {
handleInitial(page);
}, [handleInitial]);
Now, if you hit the Load More
button, it should behave as expected. Yay! 🎉. We can consider the first part of this article done. Now to the main business, how can we take what we have learned about Intersection Observer
and apply it to this app?
The first thing to consider is the approach. Using the illustration explaining the threshold above, we will like to load images once the Load More button comes into view. We can have the threshold set at 1
or 0.75
. We have to set up Intersection Observer
in React.
// create a variable called observer and initialize the IntersectionObserver()
const observer = useRef(new IntersectionObserver());
/*
A couple of things you can pass to IntersectionObserver() ...
the first is a callback function, that will be called every time
the elements you are observing is shown on the screen,
the next are some options for the observer
*/
const observer = useRef(new IntersectionObserver(entries => {}, options)
By doing this we have initialized the IntersectionObserver()
. However, initializing is not enough. React needs to know to observe or unobserve. To do this, we will be making use of the useEffect()
hook. Lets also set the threshold to 1
.
// Threshold set to 1
const observer = useRef(new IntersectionObserver(entries => {}, { threshold: 1 })
useEffect(() => {
const currentObserver = observer.current;
// This creates a copy of the observer
currentObserver.observe();
}, []);
We need to pass an element for the observer to observe. In our case, we want to observe the Load More button. The best approach to this creates a ref and pass it to the observer function.
// we need to set an element for the observer to observer
const [element, setElement] = useState(null);
<div ref={setElement} className="buttonContainer">
<button className="buttonStyle">Load More</button>
</div>
/*
on page load, this will trigger and set the element in state to itself,
the idea is you want to run code on change to this element, so you
will need this to make us of `useEffect()`
*/
So we can now update our observer function to include the element we want to observe
useEffect(() => {
const currentElement = element; // create a copy of the element from state
const currentObserver = observer.current;
if (currentElement) {
// check if element exists to avoid errors
currentObserver.observe(currentElement);
}
}, [element]);
The last thing is to set up a cleanup function in our useEffect()
that will unobserve()
as the components unmount.
useEffect(() => {
const currentElement = element;
const currentObserver = observer.current;
if (currentElement) {
currentObserver.observe(currentElement);
}
return () => {
if (currentElement) {
// check if element exists and stop watching
currentObserver.unobserve(currentElement);
}
};
}, [element]);
If we take a look at the webpage, it still doesn't seem like anything has changed. Well, that is because we need to do something with the initialized IntersectionObserver()
.
const observer = useRef(
new IntersectionObserver(
entries => {},
{ threshold: 1 }
)
);
/*
entries is an array of items you can watch using the `IntersectionObserver()`,
since we only have one item we are watching, we can use bracket notation to
get the first element in the entries array
*/
const observer = useRef(
new IntersectionObserver(
entries => {
const firstEntry = entries[0];
console.log(firstEntry); // check out the info from the console.log()
},
{ threshold: 1 }
)
);
From the console.log()
, we can see the object available to each item we are watching. You should pay attention to the isIntersecting, if you scroll the Load More button into view, it changes to true and updates to false when not in-view.
const observer = useRef(
new IntersectionObserver(
entries => {
const firstEntry = entries[0];
console.log(firstEntry);
if (firstEntry.isIntersecting) {
loadMore(); // loadMore if item is in-view
}
},
{ threshold: 1 }
)
);
This works for us, you should check the webpage and as you scroll approaching the Load More
button, it triggers the loadMore()
. This has a bug in it though, if you scroll up and down, isIntersecting
will be set to false
then true
. You don't want to load more images when you anytime you scroll up and then down again.
To make this work properly, we will be making use of the boundingClientRect
object available to the item we are watching.
const observer = useRef(
new IntersectionObserver(
entries => {
const firstEntry = entries[0];
const y = firstEntry.boundingClientRect.y;
console.log(y);
},
{ threshold: 1 }
)
);
We are interested in the position of the Load More
button on the page. We want a way to check if the position has changed and if the current position is greater than the previous position.
const initialY = useRef(0); // default position holder
const observer = useRef(
new IntersectionObserver(
entries => {
const firstEntry = entries[0];
const y = firstEntry.boundingClientRect.y;
console.log(prevY.current, y); // check
if (initialY.current > y) {
console.log("changed") // loadMore()
}
initialY.current = y; // updated the current position
},
{ threshold: 1 }
)
);
With this update, when you scroll, it should load more images and its fine if you scroll up and down within content already available.
Full Code
import React, { useState, useEffect, useCallback, useRef } from 'react';
import axios from 'axios';
export default function App() {
const [element, setElement] = useState(null);
const [loading, setLoading] = useState(false);
const [images, setImages] = useState([]);
const page = useRef(1);
const prevY = useRef(0);
const observer = useRef(
new IntersectionObserver(
entries => {
const firstEntry = entries[0];
const y = firstEntry.boundingClientRect.y;
if (prevY.current > y) {
setTimeout(() => loadMore(), 1000); // 1 sec delay
}
prevY.current = y;
},
{ threshold: 1 }
)
);
const fetchData = useCallback(async pageNumber => {
const url = `https://picsum.photos/v2/list?page=${pageNumber}&limit=15`;
setLoading(true);
try {
const res = await axios.get(url);
const { status, data } = res;
setLoading(false);
return { status, data };
} catch (e) {
setLoading(false);
return e;
}
}, []);
const handleInitial = useCallback(
async page => {
const newImages = await fetchData(page);
const { status, data } = newImages;
if (status === 200) setImages(images => [...images, ...data]);
},
[fetchData]
);
const loadMore = () => {
page.current++;
handleInitial(page.current);
};
useEffect(() => {
handleInitial(page.current);
}, [handleInitial]);
useEffect(() => {
const currentElement = element;
const currentObserver = observer.current;
if (currentElement) {
currentObserver.observe(currentElement);
}
return () => {
if (currentElement) {
currentObserver.unobserve(currentElement);
}
};
}, [element]);
return (
<div className="appStyle">
{images && (
<ul className="imageGrid">
{images.map((image, index) => (
<li key={index} className="imageContainer">
<img src={image.download_url} alt={image.author} className="imageStyle" />
</li>
))}
</ul>
)}
{loading && <li>Loading ...</li>}
<div ref={setElement} className="buttonContainer">
<button className="buttonStyle">Load More</button>
</div>
</div>
);
}
It’s important to note that to some extent, IO is safe to use and supported across most browsers. However, you can always use a Polyfill if you are not comfortable. You can refer to this to learn more about support:
Top comments (6)
I love this code, but I would like to pass in the data as a prop so that on a rerender it will access a completely new data set via props and then infinitely scroll on the new data set. Currently every time the component rerenders it continues to show whatever data was initially passed in on the first mount.
Ideas?
i had a problem with using boundingClientRect instead of isIntersecting on page load. if the element is visible on page load it does not work, but intersectionRation and isIntesecting worked. so moved into isIntesecting. if there is a better method, it would be helpful. Thanks for the post by the way.
Thank you, Muhammad. I would look into this and get back to you.
beautifully written and very insightful
Thank you Segun
Have never heard of Intersection Observer. Neat!