Infinite scrolling is a sort of pagination used in many applications like social and ecommerce apps. In this article let’s learn how to build infinite scrolling in Vue 3.
In web applications typically when displaying a list of search results, we provide users a method to navigate between pages, using pagination. Pagination can implemented using different techniques. The usual technique of pagination by displaying a page numbers as links in the bottom of search page.
Another technique of pagination by using infinite scrolling, which works when scrolling to the bottom of the page and loading the results. This method implemented on many social and mobile apps.
Let’s show how to make a simple infinite scrolling component in Vue 3 project.
Assuming you have already a Vue 3 project setup, let’s create a new component in src/
src/components/Posts.vue
<script setup> import {ref} from "vue"; </script> <template> <h2>Infinite scroll demo</h2> <section> </section> </template> <style scoped> h2 { text-align: start; } </style>
In this <Posts /> component we want to display a list of posts. For our example let’s use a dummy data Api like dummyjson.com to a render a list of data.
I added the code to fetch the data and render it in the template:
Posts.vue
<script setup> import {ref} from "vue"; const items = ref([]); const loading = ref(true); const endpoint = 'https://dummyjson.com'; const loadData = async () => { loading.value = true; setTimeout(async () => { const {posts, total} = await fetch(`${endpoint}/posts?limit=10&skip=0`).then(res => res.json()); loading.value = false; items.value = [...items.value, ...posts]; }, 1000); } // Load data on page start loadData(); </script> <template> <h2>Infinite scroll demo</h2> <section> <article v-for="post in items" :key="post.id"> <h3>{{post.title}}</h3> <p>{{post.body}}</p> <span v-for="(tag, index) in post.tags" :key="index" class="tag">{{tag}}</span> </article> <div v-if="loading" class="loader"> <i class="fa fa-spinner fa-spin"></i> loading... </div> </section> </template> <style scoped> h2 { text-align: start; } section { max-height: 600px; overflow-y: auto; } article { margin-bottom: 5px; padding-bottom: 10px; border-bottom: 1px solid #ccc; } .tag { background-color: #e5e4e4; padding: 2px 4px; margin: 4px; font-size: 13px; border-radius: 8px; } i.fa { margin-top: 10px; font-size: 30px; } </style>
In the above code i added a function loadData() that makes an ajax request using the Javascript fetch() API to fetch the posts from dummyposts /posts service. I declared two ref() variables as state which are items and loading.
Next in the loadData() function i set the loading and items to the returned data:
items.value = [...items.value, ...posts]; loading.value = false;
As you see i append to the array of items every time we make a request. Then i invoked the loadData() function in the first page load:
loadData();
You can also invoke loadData() inside the onMounted() lifecyle hook.
In the template we displayed the data using a v-for directive on the <article> element displaying the post title and description:
<article v-for="post in items" :key="post.id"> <h3>{{post.title}}</h3> <p>{{post.body}}</p> <span v-for="(tag, index) in post.tags" :key="index" class="tag">{{tag}}</span> </article>
In the bottom of the template i displayed the loading indicator using v-if:
<div v-if="loading" class="loader"> <i class="fa fa-spinner fa-spin"></i> loading... </div>
Handling Scroll
The next step which is the subject of this tutorial is loading more data when user scroll to bottom. In this step we have to listen for the scroll event on the <section> like so:
<section v-on:scroll="loadMoreData($event)"> ... ... ... </section>
The scroll event fired when user moves the scrollbar. Now let’s define the loadMoreData() function below the loadData() function:
<script setup> import {ref} from "vue"; const items = ref([]); const loading = ref(true); const limit = ref(10); const skip = ref(0); const fetchStart = ref(false); const endpoint = 'https://dummyjson.com'; const loadData = async () => { loading.value = true; setTimeout(async () => { const {posts, total} = await fetch(`${endpoint}/posts?limit=${limit.value}&skip=${skip.value}`).then(res => res.json()); loading.value = false; items.value = [...items.value, ...posts]; fetchStart.value = true; }, 1000); } // Scroll handler const loadMoreData = (ev) => { const element = ev.srcElement; const {scrollTop, clientHeight, scrollHeight} = element; if (scrollTop > 0 && scrollTop + clientHeight === scrollHeight && fetchStart.value) { fetchStart.value = false; skip.value = items.value.length; loadData(); } }; // Load data on page start loadData(); </script>
In this code i declared some extra ref() variables. The limit refers to the number of posts to fetch, the skip parameter refers to the start offset to start fetching the data. The limit and skip is sent along with the ajax request in /posts api, so we updated the loadData() function like so:
const {posts, total} = await fetch(`${endpoint}/posts?limit=${limit.value}&skip=${skip.value}`).then(res => res.json());
The fetchStart ref is a boolean, it’s purpose is to not make too many ajax requests when user scrolls two fast to the bottom. i set this variable in loadData() function after loading the data.
The loadMoreData() function triggers when the user scrolls. I captured the scrollTop, clientHeight, and scrollHeight properties from ev.srcElement. Next we need to make the scrollbar is moved to the bottom, which is done using this check:
if (scrollTop > 0)
To ensure the user reaches the end of the page, the if condition needs to be updated:
if (scrollTop > 0 && scrollTop + clientHeight === scrollHeight && fetchStart.value) {}
At this point i set the fetchStart value to false and set the skip value to be the items length using items.value.length property and then calling the loadData() again to load and append more data.
Now run the application and scroll to the bottom you see more data is loading.
Adding debounce functionality
You can add debounce functionality when making the scroll event so that the ajax request takes some delay:
function debounce(method, delay) { clearTimeout(method._tId); method._tId= setTimeout(function(){ method(); }, delay); }
Now you can wrap the loadMoreData() function body with the debounce call:
const loadMoreData = (ev) => { debounce(() => { const element = ev.srcElement; const {scrollTop, clientHeight, scrollHeight} = element; if (scrollTop > 0 && scrollTop + clientHeight === scrollHeight && fetchStart.value) { fetchStart.value = false; skip.value = items.value.length; loadData(); } }, 500); };
Extracting to reusable component
We can extract the infinite scroll into a reusable component:
InfiniteScroller.vue
<script setup> import {onMounted, onUnmounted} from "vue"; const props = defineProps(["loading"]); const emits = defineEmits(["onLoadMore"]); const handleScroll = (event) => { emits('onLoadMore', event); } onMounted(() => { window.addEventListener("scroll", handleScroll, false); }); onUnmounted(() => { window.removeEventListener("scroll", handleScroll); }); </script> <template> <section> <slot /> <div v-if="loading" class="loader"> <i class="fa fa-spinner fa-spin"></i> loading... </div> </section> </template>
The updated Posts.vue with <InfiniteScroller />:
<script setup> import {ref} from "vue"; import InfiniteScroller from "./InfiniteScroller.vue"; const items = ref([]); const loading = ref(true); const limit = ref(10); const skip = ref(0); const fetchStart = ref(false); const endpoint = 'https://dummyjson.com'; const loadData = async () => { loading.value = true; setTimeout(async () => { const {posts, total} = await fetch(`${endpoint}/posts?limit=${limit.value}&skip=${skip.value}`).then(res => res.json()); loading.value = false; items.value = [...items.value, ...posts]; fetchStart.value = true; }, 1000); } // Scroll handler const loadMoreData = (ev) => { const element = ev.srcElement.scrollingElement; const {scrollTop, clientHeight, scrollHeight} = element; if (scrollTop > 0 && scrollTop + clientHeight >= scrollHeight && fetchStart.value) { fetchStart.value = false; skip.value = items.value.length; loadData(); } }; // Load data on page start loadData(); </script> <template> <InfiniteScroller :loading="loading" @on-load-more="loadMoreData" > <article v-for="post in items" :key="post.id"> <h3>{{post.title}}</h3> <p>{{post.body}}</p> <span v-for="(tag, index) in post.tags" :key="index" class="tag">{{tag}}</span> </article> </InfiniteScroller> </template> <style scoped> h2 { text-align: start; } article { margin-bottom: 5px; padding-bottom: 10px; border-bottom: 1px solid #ccc; } .tag { background-color: #e5e4e4; padding: 2px 4px; margin: 4px; font-size: 13px; border-radius: 8px; } i.fa { margin-top: 10px; font-size: 30px; } </style>