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.
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.
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>
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 elementdisconnect
: stops observing all elementsFor 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>
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.
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.
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>
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.
Our goal is to be the number one source of Vue.js knowledge for all skill levels. We offer the knowledge of our industry leaders through awesome video courses for a ridiculously low price.
More than 200.000 users have already joined us. You are welcome too!
© All rights reserved. Made with ❤️ by BitterBrains, Inc.