September 27, 2022
When there is a need to display large set of data, most of the web applications split whole set into several smaller chunks and then serve on demand. This technique is called pagination.
Earlier, pagination looked like this:
Here, loading next set of data required user to click on next page button.
These days, we use infinite scroll technique which automatically loads next set of data when user scrolls to the bottom of the list. This is more user friendly:
Several JS libraries are available to facilitate infinite scroll. But to quench our curiosity about how things work under the hood, it is best to try to implement it from the scratch.
To implement infinite scroll, we need to know when the user has scrolled to the bottom of the list to load the next page's data. To know if the user has reached the bottom, we can watch the last element of the list. That is, when the list is scrolled and the last element becomes visible, we know that we are at the bottom.
Detecting the visibility of elements during scroll was a hard problem until
recently. We had to hook onto onscroll
events of the element and check the
boundaries of the elements using
getBoundingClientRect
function.
Since onscroll
event gets fired around 40-50 times per second, performing the
operations inside it will become expensive. Moreover, the onscroll
function
gets executed from the main UI thread. All these together make our web
application sluggish.
But now, we have a much more performant alternative for this problem. All popular web browsers support a new API named IntersectionObserver from 2019 onwards.
The advantages of IntersectionObserver
API are:
The introduction of IntersectionObserver
simplified a whole set of
requirements like:
In this blog, we are going to discuss how we can use IntersectionObserver
in a
React application as hooks.
We will create a custom hook that will update whenever the specified component
scrolls into view and scrolls out of view. Let us name the hook
useIsElementVisible
. Obviously, it will accept a reference to the component of
which visibility need to be monitored as its argument.
It will have a state to store the visibility status of the specified element. It
will have a useEffect hook from which we will bind the IntersectionObserver
to
the specified component.
Here is the basic implementation:
import { useEffect, useState } from "react";
const useIsElementVisible = target => {
const [isVisible, setIsVisible] = useState(false); // store visibility status
useEffect(() => {
// bind IntersectionObserver to the target element
const observer = new IntersectionObserver(onVisibilityChange);
observer.observe(target);
}, [target]);
// handle visibility changes
const onVisibilityChange = entries => setIsVisible(entries[0].isIntersecting);
return isVisible;
};
export default useIsElementVisible;
We can use useIsElementVisible
like this:
const ListItem = () => {
const elementRef = useRef(null); // to hold reference to the component we need to track
const isElementVisible = useIsElementVisible(elementRef.current);
return (
<div ref={elementRef} id="list-item">
{/* your component jsx */}
</div>
);
};
The component ListItem
will get updated whenever the user has scrolled to see
the div "list-item"
. We can use the value of isElementVisible
to load the
contents of the next page from a useEffect
hook:
useEffect(() => {
if (isElementVisible && nextPageNotLoadedYet()) {
loadNextPage();
}
}, [isElementVisible]);
This works in theory. But if you try it, you will notice that this doesn't work as expected. We missed an edge case.
We use a useRef
hook for referencing the div
. During the initial render,
elementRef
was just initialized with null
as its value. So,
elementRef.current
will be null and as a result, the call
useIsElementVisible(elementRef.current)
won't attach our observer with the
element for the first time.
Unfortunately, useRef hook won't re-render when a value is set to it after DOM is prepared. Also, there are no state updates or anything that requests a re-render inside our example component. In short, our component will render only once.
With these in place, useIsElementVisible
will never get reference to the
"list-item"
div in our previous example.
But there is a workaround for our problem. We can force render the component twice during the first mount.
To make it possible, we will add a dummy state. When our hook is called for the
first time (when ListItem
mounts), we will update our state once, thereby
requesting React to repeat the component render steps again. During the second
render, we will already have our DOM ready and we will have the target element
attached to elementRef
.
To keep our code clean and modular, let us create a dedicated custom hook for managing force re-renders:
import { useState } from "react";
const useForceRerender = () => {
const [, setValue] = useState(0); // we don't need the value of this state.
return () => setValue(value => value + 1);
};
export default useForceRerender;
Now, we can use it in our useIsElementVisible
hook this way:
const useIsElementVisible = target => {
const [isVisible, setIsVisible] = useState(false);
const forceRerender = useForceRerender();
useEffect(() => {
forceRerender();
}, []);
// previous code to register observer
return isIntersecting;
};
With this change, our hook is now self-sufficient and fully functional. In our
ListItem
component, isElementVisible
will update to false
and trigger
component re-render whenever our "list-item"
div goes outside visible zone
during scroll. It will also update to true
when it is scrolled into visibility
again.
The useIsElementVisible
hook shown in the previous sections serves only the
basic use case. It is not optimal to use in a production world.
These are the scopes for improvement for our hook:
target
is not ready yet (when
it is null
).Here is what the optimum code for the hook should look like:
import { useEffect, useState } from "react";
export const useForceRerender = () => {
const [, setValue] = useState(0); // we don't need the value of this state.
return () => setValue(value => value + 1);
};
export const useIsElementVisible = (target, options = undefined) => {
const [isVisible, setIsVisible] = useState(false);
const forceUpdate = useForceRerender();
useEffect(() => {
forceUpdate(); // to ensure that ref.current is attached to the DOM element
}, []);
useEffect(() => {
if (!target) return;
const observer = new IntersectionObserver(handleVisibilityChange, options);
observer.observe(target);
return () => observer.unobserve(target);
}, [target, options]);
const handleVisibilityChange = ([entry]) =>
setIsVisible(entry.isIntersecting);
return isVisible;
};
If this blog was helpful, check out our full blog archive.