Using IntersectionObserver for your infinite scroll needs
IntersectionObserver can make your single page app flow better than ever
If you've ever tried adding infinite scrolling to a website, you know it can be a challenge. One of the most popular ways of doing it is to listen to scroll events and then act on that information, like so:
window.addEventListener('scroll', () => {
//do something here
})
Most often, that do something here bit involves things like:
Getting both the client height and scroll height of the DOM's document object and comparing how far down the window you've scrolled, then:
Calculating if the combined client height and window's scroll position is greater than the scroll height of the document (whew), and finally:
Using that information to determine if you need to add a new element to the bottom of the page.
...If all that sounds confusing that's because it is.
Infinite Scroll is a CPU hog
Nearly as bad as keeping track of all the document and window-scroll positioning is the fact that listening for scroll events is a thread and CPU-intensive procedure. On a busy webpage or a browser with 10+ tabs open (like yours right now) it can lead to a choppy scrolling experience.
That's no fun, but today we can do something about it, and keep the head-scratching code to a minimum. That's due to the IntersectionObserver.
What is IntersectionObserver? Think of it as an API that checks whether two DOM elements (a root
and a target
) overlap and intersect. In brief, you choose a top level element as your root, along with percentage 'thresholds' you want to be alerted about whenever a target element intersects with that root.
So if the document itself is your root, and a target <div> element is one of the targets, the IntersectionObserver will fire a callback function whenever that target overlaps (10% / 27% / 74%/ whatever%) with the document viewport. You choose what the threshold will be to fire the callback. What's nice is the IntersectionObserver only cares about whether thresholds are passed, and doesn't rely on constantly listening to miniscule changes in target visibility, giving a nice performance boost to more traditional infinite scrolling solutions.
One word of note-- IntersectionObserver has been natively adopted into all the major browsers since late 2017, so if you're up to date in Chrome, FireFox, Edge or Safari you'll have no trouble with the demos. Otherwise, polyfills are available for older browsers.
How does this help with infinite scrolling? Let's dig into an example to get a better idea on how to use it. Open this Codepen in a new tab or window and we'll walk through a quick example.
A Beer Menu That Never Ends
We'll have some fun by displaying a never-ending list of beers with their ingredients and recommended food pairings. The impetus for this Infinite Scroll sample comes from Sarah Drasner and her excellent twist on the idea using Vue.js and a more traditional infinite-scrolling technique. Our thanks to Sarah for providing the idea along with the CSS that makes it look snazzy. Also, Chris Nwamba discusses IntersectionObserver at scotch.io, here
In our case, we're using React.js but don't let that scare you; if you're new to it, the real lesson today is how to use the IntersectionObserver. The React code we'll use is fairly basic and we'll walk through it slowly below.
We start by establishing a React component that will drive the program. In its constructor we set a few variables for later use:
class App extends React.Component {
constructor(props) {
super(props);
this.state = {
loading: false,
previousY: 0,
beerList: []
};
}
React has a concept of 'state', which in the easiest explanation is a way to store internal data for your components. In this case we have three pieces of state:
A
loading
variable that is used to flash a 'loading...' message whenever we hit an external API to pull beer information.An array named
beerList
that holds all the beers we've downloaded and displayed at any given time.previousY
represents how far down we have scrolled when an intersection occurs and is saved each time a new beer is added to the page.
The app starts up by calling into an API that supplies beer information. We do this in a React lifecycle method known as componentWillMount()-- which is the recommended place to make initial ajax and API calls in a React component. (Fancy name, mundane purpose.)
componentWillMount()
calls a getBeer()
method, as shown below:
getBeer() {
this.setState({ loading: true });
axios.get("https://api.punkapi.com/v2/beers/random").then(response => {
let api = response.data[0];
let newBeer = {
name: api.name,
desc: api.description,
img: api.image_url,
tips: api.brewers_tips,
tagline: api.tagline,
food: api.food_pairing
};
this.setState({ beerList: [...this.state.beerList, newBeer] });
this.setState({ loading: false });
});
}
In this method we set the state variable we defined earlier, the one called loading
, to 'true'; this activates some HTML in the component to show a <div> tag with the phrase loading... in it. Then, we make an vanilla ajax call to an external API to get a random beer and its associated information, and save the result into an object called newBeer
.
Finally, we set some more state: this time, we append our newBeer to the beerList array and set the loading
variable to false, which hides the loading... <div>.
After calling getBeer()
to initialize our beerList with one beer, componentWillMount()
gets busy setting up the IntersectionObserver:
this.getBeer();
var options = {
root: null,
rootMargin: "0px",
threshold: 1.0
};
this.observer = new IntersectionObserver(
this.handleObserver.bind(this),
options
);
this.observer.observe(this.loadingRef);
}
Above we create an options
object that's used in building the IntersectionObserver. In it, the root element is set to null
, which means we use entire document as the root. We also set a margin of 0 pixels and a threshold percentage of 100, meaning the entire target element has to intersect with the root for anything to happen.
We next create an actual IntersectionObserver. In its constructor we pass our options object, along with the callback method --here called handleObserver
-- that is fired when an intersection occurs.
this.observer = new IntersectionObserver(
this.handleObserver.bind(this),
options
);
You might be wondering about the this.handleObserver.bind(this)
stuff; it's just typical, boilerplate React code to get the this
reference correct at runtime and tie handleObserver to our React component.
After creating the IntersectionObserver we call its observe()
method and pass in a target element we'll use to test against the root; in this case it's a <div> called 'loadingRef'. (You can add multiple target elements to test against, but in this example we're only adding one.)
this.observer.observe(this.loadingRef);
/*
call observer.observe for each target you want to test against
*/
So at this point we have created an IntersectionObserver with its root element being the entire document, and a target element called 'loadingRef'. Due to the way we set it up, the intersection callback is fired when the target element intersects 100% with the root.
With those preliminaries out of the way, let's see the IntersectionObserver in action.
Using the IntersectionObserver
Here we come to the heart of the matter, the handleObserver
method:
handleObserver(targets, observer) {
const targetY = targets[0].boundingClientRect.y;
if (this.state.previousY> targetY ) {
this.getBeer();
}
this.setState({ previousY: targetY });
}
There's a bit to digest here so let's walk through it. As part of the API signature the handleObserver
callback is passed a targets
array that represents all target elements the IntersectionObserver is registered to test against.
Since in this example we only have one target element, we pull the first element on the array and retrieve the y coordinate of its bounding rectangle, which is provided by the API:
const targetY = entities[0].boundingClientRect.y
From there, we compare targetY
with the previousY
piece of state, which is initialized to zero at the start of the application and represents how far down the page we have scrolled to that point.
If previousY
is greater than targetY
it means we've scrolled down far enough to intersect 100% with the target div --otherwise handleObserver
would not have been called-- and we need to add another beer to the list: thus the call to this.getBeer()
:
if (this.state.previousY> targetY ) {
this.getBeer();
}
This adds another beer to our beerList, React recognizes this and updates our UI appropriately with a new beer on the screen.
As a result of the new beer appearing in the document, our target div is pushed down and it no longer intersects 100% with the document, and won't until we scroll down again.
For bookkeeping purposes we then update previousY
with the targetY
value, thereby storing our scroll position when the new beer information was added to the page. We'll use this value next time through when we scroll down again and compare it with the target's current y coordinate.
There's your infinite scroll sequence. In a future article, we'll delve a little more into IntersectionObserver and look at its potential in creating a lightweight ad tracker, sending notifications to a server every time a particular ad box is scrolled past.