Last time, we’ve seen how to implement the useAudio Hook to simplify sounds management within our React apps. Today, let’s see how to create a Hook that tracks the visibility our components on the screen: useInView
.
Motivation
First of all, let’s have a look at a concrete example to motivate our Hook implementation. The most obvious example is the one of infinite scrolls, like your Facebook or Instagram feed. You probably know that these applications don’t load entirely all of the content you see on your screen (otherwise, the DOM would contain thousands of elements, which would drastically impact the performance of the application). Instead, they load a certain amount of posts, typically a dozen, and wait for you to scroll in order to load a dozen more, until you scroll enough to load another dozen, and so on. This way, scrolling your feed feels like you are facing an infinite list.
To achieve this, they use something called intersection observers that wait for a given element to be above a given “threshold” (called the root margin), as shown in the figure below.
When more than the defined threshold (let’s say 25%) of the 4th element will be above the root margin, a callback function will be triggered in order to load more posts. This is what our custom Hook will be in charge of.
If intersection observers are still confusing for you, don’t worry: I’ve set up a small demo project that you can play with to easily understand how they work. Just head over to this CodeSandbox demo to try it out.
We can now get our hands dirty and jump into the Hook implementation. 👨🏻💻
Implementation
Before implementing this new Hook, let’s discuss its signature: how do we want to use it? Typically, we want it to return a boolean value to know if the target element is visible or not.
const isIntersecting = useInView()
But we have a first problem: as we’ve seen in the diagram of the previous section, the root margin is not directly the bottom of the screen. Instead, it will be an offset that we can manually set (like 100px
). We can also define the root element, from which the target element will be visible or not, as well as a visibility threshold for it (0%
, 50%
, 100%
...). In our infinite scroll example, the root element corresponds to the browser viewport, the root margin could be set to 100px
, the threshold could be 0
, and the target element would be the footer of the page. This way, when the footer will reach 100px
from the bottom of the screen, the function for loading new posts will be triggered.
On the official documentation of intersection observers, we can see that an options
object can be specified when creating a new instance. We will use the same object in order to keep the same logic instead of creating our own one, which could be confusing to our colleagues. Thus, our Hook signature can be changed to the following:
const options = { root: someElement, rootMargin: '100px', threshold: 0.25 }
const isIntersecting = useInView(options)
Each key of the
options
object is optional.
That’s way better, but we still have a last problem: where do we specify the target element to use? Actually, we don’t want to bother with the DOM native methods, such as document.querySelector
or document.findElementById
. Instead, we will use the React refs (references) to simplify this. The Hook will now have another parameter: the React reference of the target element.
const target = useRef(null)
const options = { ... }
const isIntersecting = useInView(target, options)
...
<p ref={target}>Loading...</p>
Note that the options parameter is optional, and could be omitted.
This looks awesome. We are now ready to dive in the actual implementation. Let’s first setup the Hook skeleton.
const useInView = (target, options = {}) => {
const [isIntersecting, setIsIntersecting] = useState(false);
return isIntersecting;
}
Then, we are going to setup the intersection observer logic. We will create an instance of IntersectionObserver
when the Hook is mounted. As we’ve seen previously, the constructor takes as a parameter a callback function, that will be executed when the target meets the threshold specified for the IntersectionObserver
. This function receives as an argument the list of entries, each one being a threshold that was crossed by one of the observed elements (but in our case, we will only have one target element, so this list will only contain this single element). Hence our callback function is as simple as that:
const callback = (entries) => {
setIsIntersecting(entries[0].isIntersecting);
}
Great. With this callback function and the options that we receive as arguments, we can now instantiate an IntersectionObserver
.
const useInView = (target, options = {}) => {
const [isIntersecting, setIsIntersecting] = useState(false);
const [observer, setObserver] = useState(null);
useEffect(() => {
const callback = (entries) => {
setIsIntersecting(entries[0].isIntersecting);
};
const _observer = new IntersectionObserver(callback, options);
setObserver(_observer);
}, []);
return isIntersecting;
}
So far, nothing happens. This is because we need to actually observe the given target after creating the instance, so that the callback gets called accordingly.
useEffect(() => {
const callback = (entries) => {
setIsIntersecting(entries[0].isIntersecting);
};
const _observer = new IntersectionObserver(callback, options);
_observer.observe(target);
setObserver(_observer);
}, []);
Awesome. However, we need to be careful: when our Hook is unmounted, we have to stop watching the target element visibility changes so that the callback is not accidentally called. To do so, we just have to return a cleanup function inside the useEffect
Hook that will take care of disconnecting from the observer when the Hook is unmounted.
useEffect(() => {
const callback = (entries) => {
setIsIntersecting(entries[0].isIntersecting);
};
const _observer = new IntersectionObserver(callback, options);
_observer.observe(target);
setObserver(_observer);
return () => {
observer?.disconnect();
};
}, []);
ℹ️ We are using the optional chaining operator (
?.
) to prevent from errors if for some reasons the observer is stillnull
.
That’s way better! We are getting close to our goal. We just have one last thing to deal with: we have to listen for value changes of the observer arguments. For example, if the target element or the threshold are changed, we have to update our observer accordingly. To do so, we just have to add them to the dependency array of the useEffect
Hook, where we will disconnect from the previous observer (if it exists), and recreate a new instance with updated values.
useEffect(() => {
const callback = (entries) => {
setIsIntersecting(entries[0].isIntersecting);
};
observer?.disconnect(); // Disconnect from the previous observer
// target.current can be null, in which case we do nothing
if (target.current) {
const _observer = new IntersectionObserver(callback, options);
_observer.observe(target);
setObserver(_observer);
}
return () => {
observer?.disconnect();
};
}, [target.current, options.root, options.rootMargin, options.threshold]);
And that's it, we are now done with our Hook. Here is the final implementation.
const useInView = (target, options = {}) => {
const [isIntersecting, setIsIntersecting] = useState(false);
const [observer, setObserver] = useState(null);
useEffect(() => {
const callback = (entries) => {
setIsIntersecting(entries[0].isIntersecting);
};
observer?.disconnect();
if (target.current) {
const _observer = new IntersectionObserver(callback, options);
_observer.observe(target);
setObserver(_observer);
}
return () => {
observer?.disconnect();
};
}, [target.current, options.root, options.rootMargin, options.threshold]);
return isIntersecting;
}
Usage
In the first part of this article, we’ve talked about Facebook to introduce intersection observers. We are going to simulate a competitor of it, Fuzebook. In the following snippet, we load the user’s feed. The target element for our intersection observer will be a paragraph at the bottom of the page, containing “Loading more posts...”. We could also have used another element as the target, such as a spinner or even the page footer. Either way, the target shouldn’t be visible since we’ve defined a root margin of 150px. In fact, it could be visible if the fetch
call (getPosts
) takes a few seconds.
In our case, the scroll is infinite: we are never going to reach the bottom of the page since our Facebook feed typically contains hundreds (if not thousands) of posts. If your scrolling section contains fewer elements (let’s say a hundred), keep in mind that you can handle the case where you have no more elements to load (in which case you would just hide the target
element).
Below is the code of the main component of our Fuzebook application.
const App = () => {
const posts = useArray([]);
const page = useCounter(1);
const loadingElement = useRef(null);
const isIntersecting = useInView(loadingElement, {
threshold: 1,
rootMargin: "150px"
});
// Load next page posts
useEffect(() => {
getPosts(page.value).then((newPosts) => {
posts.concat(newPosts);
});
}, [page.value]);
useEffect(() => {
if (isIntersecting) {
page.increment();
}
}, [isIntersecting]);
return (
<div className="App">
<h1>Fuzebook</h1>
<ul>
{posts.value.map((post, i) => (
<Card key={i} post={post} />
))}
</ul>
<p ref={loadingElement}>Loading more posts...</p>
</div>
);
}
As you can see, we’ve reused two Hooks that we’ve implemented in previous articles: useArray and useCounter. This simplifies our App
component, resulting in a very clean and readable code. The Card
component renders a basic post card, and the getPosts
function (that returns a promise) just fetches any rest API to get some posts.
Wrapping Up
This article closes the Custom React Hooks series, during which we’ve discovered how to extract some logic into reusable functions in order to simplify our code. This also allowed us to reuse common logic through our application, avoiding code duplication. By doing so, we were following the Single Responsibility Principle for both our components (that now only focus on their responsibility) and our Hooks. I hope you enjoyed following this series as much as I enjoyed writing it, and I’ll see you in upcoming articles. 👋
Source code available on CodeSandbox.
Top comments (3)
Très fan de tes posts 😀
Content qu'ils te plaisent ! 🙂
Some comments may only be visible to logged-in visitors. Sign in to view all comments.