Everyone who has worked in web development knows how important it is to provide good UX for the users of your application. A big part of that is the ability to take different actions based on what the user is currently seeing.
This could be an infinite scroll — when the user reaches the bottom of a list of items you load the next ones in advance. Lazy loading images is another great example — no need to load something and risk a performance hit if the user never gets to see it anyway.
While those are the most common practices used today there are many other use cases that depend on the business logic of your application. For example, maybe you want to calculate ad revenue based on how many times the ad was seen.
Historically this has been done by using functions like getClientBoundingRect to calculate whether the element is fully or partially in view.
While this works, attaching this a function like that to a scroll listener that fires an industrial quantity of events each time it’s triggered can lead to lagging and a sub-optimal user experience. Having multiple events that we need to keep track of makes the performance problems even worse.
What’s counterintuitive here is that most of the time we will be running those calculations for no reason. Even if we debounce the events if the element we are tracking is on the bottom of a list, most of the time it won’t be in view.
The Intersection Observer API allows us to watch a certain element and run a callback when its visibility changes in relation to the view port or another element.
We can specify an intersection ratio to tell the Intersection Observer what percentage of our element needs to be visible in order to trigger the callback. This must be a value between 0.0 and 1.0.
Another configuration option that needs to be specified when we initialise the Intersection Observer is the root. This is the element in relevance to which we will be watching for intersections. Most of the time this will be the full view port which means that we need to pass null as a value.
Here’s how we initialise an Intersection Observer which will run a callback every time our target is fully visible in the view port of the user:
To specify the element that we actually want to watch we need to use the observe method and pass it a DOM element.
When an element that meets the threshold requirements enters the view port it will run the callback we provided when we initialised the Intersection Observer and it will pass it an array of entries. Since we are watching a single element we need only the first member of the array.
How do we use the Intersection Observer in an application built with React? Since modern front-end frameworks are built around the idea of components we must think of a way to wrap them and make tracking visibility as reusable as possible. One way to do that would be with render props.
The conditional statement in the callback is because the Intersection Observer fires once upon initialisation. I couldn’t find a way around that but if you do please share with me. This component can then be used as a wrapper around what we want to track.
This implementation fires only when the element is fully visible. If we want to track when the element starts getting into view or maybe half of it is visible we can provide an array of values to the threshold option and then check what the intersectionRatio is in the callback to call the appropriate function.
At the time of this writing, React hooks are still in alpha and are not in an official release but to be on the cutting edge let’s make a custom hook implementation with TypeScript.
The Intersection Observer API is supported in the latest versions of modern browsers with Safari being the only exception. The first time I used it I spent a week with a website in production that was broken only under Safari and I couldn’t understand why.
Thankfully there is a polyfill we can use to solve the problem. If we are building a normal client rendered application we can just import it in our main App file. However, if we are using Next.js or something else that provides Server Side Rendering this won’t work. We will need to make sure that the app is rendered and has access to the window object before importing the polyfill.
Then there’s another caveat — we need to be sure that the polyfill is loaded before the initialisation takes place. This is the solution that I’m using right now.
Make sure to move the logic in a separate function if you are using hooks since async functions are not supposed to be passed to useEffect.
Originally published at alexkondov.com.