Custom React Hooks: useInView

iamludal

Ludal 🚀

Posted on February 26, 2022

Custom React Hooks: useInView

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.

Intersection Observers Diagram

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()


Enter fullscreen mode Exit fullscreen mode

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)


Enter fullscreen mode Exit fullscreen mode

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>


Enter fullscreen mode Exit fullscreen mode

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;
}


Enter fullscreen mode Exit fullscreen mode

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); 
}


Enter fullscreen mode Exit fullscreen mode

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;
}


Enter fullscreen mode Exit fullscreen mode

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);
}, []);


Enter fullscreen mode Exit fullscreen mode

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();
  };
}, []);


Enter fullscreen mode Exit fullscreen mode

ℹ️ We are using the optional chaining operator (?.) to prevent from errors if for some reasons the observer is still null.

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]);


Enter fullscreen mode Exit fullscreen mode

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;
}


Enter fullscreen mode Exit fullscreen mode

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>
  );
}


Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
iamludal
Ludal 🚀

Posted on February 26, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related