Build an Infinite Scroll component using Intersection Observer API

When building applications you often come across the case where you need to design a list or search interface for the user. They usually manage lots of data, thus you need a way for the user to show it “in chunks” so that we keep the application performance and the data organized.

You’ve probably built a pagination component. They work great an provide a nice way to navigate through structured data. You can easily jump through pages and sometimes jump through several pages.

An infinite scroll is a nice alternative to a pagination component that can provide a better UX, especially on mobile and touchable devices. It provides a transparent pagination as the user scroll on the page, giving the feeling of being navigating through a no-ending list.

Since the Intersection Observer API landed on the browsers, building an infinite scroll component has never been that easy. Let’s see how to build it.

Intersection Observer API

The Intersection Observer API gives you a subscribable model that you can observe in order to be notified when an element enters the viewport.

Creating an intersection observer instance is easy, we just need to create a new instance of IntersectionObserver and call the observe method, passing a DOM element:

const observer = new IntersectionObserver();

const coolElement = document.querySelector("#coolElement");
observer.observe(coolElement);

But how do we get notified when the observer enters the coolElement enters the viewport? The IntersectionObserver constructor accepts a callback as an argument that we can use for that:

const observer = new IntersectionObserver(entries => {
  const firstEntry = entries[0];
  if (firstEntry.isIntersecting) {
    // Handle intersection here...
  }
});

const coolDiv = document.querySelector("#coolDiv");
observer.observe(coolDiv);

As you can see, the callback receives entries as its argument. It’s an array because you can have several entries when you use thresholds, but this is not the case so we’ll get just the first element.
Then we can check wether it is intersecting or not by using the firstEntry.isIntersection property. That’s a great place to make an ajax call and retrieve the next data page.

The IntersectionObserver constructor receives an options component as its second argument with the following notation:

const options = {
  root: document.querySelector("#scrollArea"),
  rootMargin: "0px",
  threshold: 1.0
};

const observer = new IntersectionObserver(callback, options);

rootMargin can be useful for our case since it gives us a way to define a margin that the observer will use to look for intersections. By default, it’s 0, meaning the observer will trigger the intersect event as soon as it enters the viewport. But setting a rootMargin of 400px means that the intersect callback will trigger exactly 400px before the element enters the viewport.

Because root and threshold don’t make much sense for this case (thus out of scope), I leave it up to you to investigate about them in the docs.

Knowing how to use an Intersection Observer, we can use the technique of placing an Observable component at the end of the list in order to add more data when the user reaches the bottom of the list.

Observer Component

The previous example is cool, right? But it’s convenient for us to have a Vue component for it so we can use it in our Vue app.

We can use a mounted hook to set up the observer that we need to keep in a component state variable. It’s important that you use the mounted hook instead of the created, since we need a DOM Element to observe, and in the created hook we don’t have it:

// Observer.vue
export default {
  data: () => ({
    observer: null
  }),
  mounted() {
    this.observer = new IntersectionObserver(([entry]) => {
      if (entry && entry.isIntersecting) {
        // ...
      }
    });

    this.observer.observe(this.$el);
  }
};

Note: we’re using array destructuring on the *[entry]* argument. That’s a shorthand way equivalent to getting the entries array and access the first element as *entries[0]*.

As you can see, we’re using this.$el which is the root element of the component as its observable DOM Element.

In order to make it reusable, we need to let the parent component (the one who uses the Observer component) handle the intersected event. For that we can emit a custom event intersect when it intersects:

export default {
  mounted() {
    this.observer = new IntersectionObserver(([entry]) => {
      if (entry && entry.isIntersecting) {
        this.$emit("intersect");
      }
    });

    this.observer.observe(this.$el);
  }
  // ...
};

As per the template of the component, we just need any element, so we can use a <div> without any dimensions:

<template>
  <div class="observer"/>
</template>

Master Vue.js with Vue School

Top notch Vue.js courses and over 200 lessons for just $12 per month!

Finally, it’s important to clean the observer when the component is destroyed, otherwise, we’ll be adding memory leaks in our application since the event listeners won’t be cleaned up. We can use the destroyed hook in order to call the observer disconnect method:

export default {
  destroyed() {
    this.observer.disconnect();
  }
  // ...
};

You’ll find out that there is also the unobserve method. The main difference is:

  • unobserve: stops observing an element
  • disconnect: stops observing all elements

For this case, since we only have one element, both of them will work.

We can also add an options property in order to pass the Intersection Observer options down to it, in case we want to use a rootMargin.

Putting all together into the Observer.vue component:

<!-- Observer.vue -->
<template>
  <div class="observer"/>
</template>

<script>
export default {
  props: ['options'],
  data: () => ({
    observer: null,
  }),
  mounted() {
    const options = this.options || {};
    this.observer = new IntersectionObserver(([entry]) => {
      if (entry && entry.isIntersecting) {
        this.$emit("intersect");
      }
    }, options);

    this.observer.observe(this.$el);
  },
  destroyed() {
    this.observer.disconnect();
  },
};
</script>

Building the Infinite Scroll

Imagine you have a list component similar to the following:

<template>
  <div>
    <ul>
      <li class="list-item" v-for="item in items" :key="item.id">
        {{item.name}}
      </li>
    </ul>
  </div>
</template>

<script>
export default {
  data: () => ({ items: [] }),
  async mounted() {
    const res = await fetch("https://jsonplaceholder.typicode.com/comment");
    this.items = await res.json();
  }
};
</script>

Note: this code is using the async/await modern syntax to make the async code look beautiful. See this article to learn more about it

This component has an items state variable rendered into a list using v-for. In a mounted hook, it uses the Fetch API in order to get some mock data from jsonplaceholder.typicode.com, used to fill up the items variable.

Adding pagination

That’s cool and it will work, but its not yet using any kind of pagination. For that, the endpoints from jsonplaceholder.typicode.com allow us to use _page and _limit in order to control the data returned. Additionally, we need to keep track of a page variable, starting by 1.

Let’s apply those changes in order to have pagination in place:

export default {
  data: () => ({ page: 1, items: [] }),
  async mounted() {
    const res = await fetch(
      `https://jsonplaceholder.typicode.com/comments?_page=${
        this.page
      }&_limit=50`
    );

    this.items = await res.json();
  }
};

Now we have pagination, limited to 50 elements per page.

Adding the Observer component

We still need the final building block for creating an infinite scroll component: the observer component. We’ll use it at the bottom of the list, in the way that when it reaches the viewport, it will fetch the next page and increase the page.

First, import the Observer component and add it to the InfiniteScroll component, right below the list:

<template>
  <div>
    <ul>
      <li class="list-item" v-for="item in items" :key="item.id">{{item.name}}</li>
    </ul>
    <Observer @intersect="intersected"/>
  </div>
</template>

<script>
import Observer from "./Observer";

export default {
  // ...
  components: {
    Observer
  }
};
</script>

Finally, we can move the code we have on the mounted hook into an intersected method, called on the intersect custom event of the Observer component.

export default {
  data: () => ({ page: 1, items: [] }),
  methods: {
    async intersected() {
      const res = await fetch(
        `https://jsonplaceholder.typicode.com/comments?_page=${
          this.page
        }&_limit=50`
      );

      this.page++;
      const items = await res.json();
      this.items = [...this.items, ...items];
    }
  }
};

Keep in mind that we have to increase the page. Additionally, now we have to append the items to the existing this.items array. We’re doing that by using the spread operator on the line: this.items = [...this.items, ...items]. That’s basically the equivalent to the older-way this.items = this.items.concat(items).

The infinite scroll component, all together:

<!-- InfiniteScroll.vue -->
<template>
  <div>
    <ul>
      <li class="list-item" v-for="item in items" :key="item.id">{{item.name}}</li>
    </ul>
    <Observer @intersect="intersected"/>
  </div>
</template>

<script>
import Observer from "./Observer";

export default {
  data: () => ({ page: 1, items: [] }),
  methods: {
    async intersected() {
      const res = await fetch(`https://jsonplaceholder.typicode.com/comments?_page=${
        this.page
      }&_limit=50`);

      this.page++;
      const items = await res.json();
      this.items = [...this.items, ...items];
    },
  },
  components: {
    Observer,
  },
};
</script>

Wrapping Up

An infinite scroll component is a nice way for paginating data, especially on mobile and touchable devices. With the addition of the Intersection Observer API, it’s even easier. In this article, we’ve gone through all the necessary steps to build one yourself.

Keep in mind that, if you need support for old browsers, you probably need the W3C’s Intersection Observer and Github’s fetch polyfills.

You can see the demo of this code working on Codesandbox.

Master Vue.js with Vue School

Top notch Vue.js courses and over 200 lessons for just $12 per month!


Article written by Alex Jover Morales

Passionate web developer. Author of Test Vue.js components with Jest on Leanpub. I co-organize Alicante Frontend. Interested in web performance, PWA, the human side of code and wellness. Cat lover, sports practitioner and good friend of his friends. His bike goes with him.