At the time of writing, Vue.js version 3 is in beta. However, that doesn’t mean we can’t start using it. In fact, this the best time to start experimenting with the new API and get ready for the official release.
In this tutorial, we will be building an infinite scroll hook with the new Composition API. we will be creating reactive-data, computed values, and using lifecycle methods. You can find the final code here.
You can use your own server to pull data from or use the one that I have built with Express, you can clone it from GitHub and start it by running:
node app
The API will return a list of users with their avatars that we will infinitely scroll. The response will be:
{ "data": [{}], "totalPages": 50, "page": 1, "limit": 10 }
Vue 3 Project Setup
To set up our Vue 3 environment we will generate a new app using the Vue CLI with only Babel and ESLint for simplicity:
vue create infinite-scroll

Then, inside the folder that was generated, we will add the vue-next
plugin that will install version 3 beta of vue and change our main.js file implementation:
vue add vue-next
import { createApp } from 'vue'; import App from './App.vue' createApp(App).mount('#app')
We can start the app by running npm run serve
and we should see the welcome screen.
Vue 3 Infinite Scroll
We will be implementing the feature using the Composition API. We will have reactive data that will hold our user’s data, and the requests loading state. Furthermore, will use the Fetch API to fetch results from the Node.js server.
Let’s start by cleaning the App.vue, we will remove the HelloWorld
component and the image:
<template> <div id="app"> Clean </div> </template> <script> export default { } </script> <style> #app { font-family: Avenir, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-align: center; color: #2c3e50; margin-top: 60px; } </style>
Next, we will create the setup method, in this method we can use the Composition API to create reactive data and register lifecycle hooks on the component.
Fetching Initial Data
We’ll start by fetching the users. to do this that, we will create a users
ref and a fetchUsers
method
<template> <div id="app"> {{ users }} </div> </template> <script> import { ref, onBeforeMount } from 'vue' export default { setup() { const users = ref([]) const fetchUsers = async () => { try { const response = await fetch('http://localhost:3000/api/v1/users') const parsedResponse = await response.json() users.value = parsedResponse.data } catch(err) { console.log(err) // state-of-the-art-error-handling } } // fetch users before the component is mounted onBeforeMount(() => { fetchUsers() }) // return the data/methods that we'll use in the template return { users } } } </script>
By using the onBeforeMount
lifecycle hook we will fetch the data before the component is mounted on the DOM. If everything went well we should see the array of users in the browser.
Now let’s go ahead and display this data in a nicer way. We will loop over it display the image and the user name under it:
<template> <div id="app"> <ul class="users"> <li v-for="user in users" :key="user.id" class="user" > <img :src="user.avatar" /> <h3>{{ user.name }}</h3> </li> </ul> </div> </template> <script> // ... code </script> <style scoped> #app { font-family: Avenir, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-align: center; color: #2c3e50; margin-top: 60px; } .users { padding-bottom: 5rem; } .user { list-style: none; } </style>

Well, that’s a bit nicer isn’t it! Next step would be to implement the infinite scrolling.
Fetching More Data
First, we have to detect when the user is at the bottom of the screen, we do that by adding a scroll event handler on the window object. Then, we compare the current scrollY
plus the window’s innerHeight
to the page’s body offsetHeight
:
<script> import { ref, onUnmounted, onMounted } from 'vue' export default { setup() { // ... code const handleScroll = () => { if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight) { console.log('yay!') } } // .. code // this will register the event when the component is mounted on the DOM onMounted(() => { window.addEventListener('scroll', handleScroll) }) // We then unregister the listener when the component is removed from the DOM onUnmounted(() => { window.removeEventListener('scroll', handleScroll) }) // ...code } } </script>
We have used two more lifecycle hooks, the onMounted
which will be called when the component is added to the DOM. This is where we register the scroll event handler. And the onUnmounted
is where the component is going to be removed from the DOM, and that’s where we remove the event listener. This is necessary to avoid memory leaks.
Now since our server supports pagination, we will use that for fetching the data when scrolling. First, we will load the first page with a limit of 5 results. Next, when we’re at the bottom of the screen we will load the next page… and so on until we run out of data.
For this, we need to introduce 2 new reactive data, currentPage
and totalPages
, and one constant, limit
. First, we will use them inside the fetchUsers
method:
<script> import { ref, onBeforeMount, onMounted, onUnmounted } from 'vue' export default { setup() { const users = ref([]) const currentPage = ref(0) const totalPages = ref() const limit = 5 const fetchUsers = async () => { currentPage.value++ try { const response = await fetch(`http://localhost:3000/api/v1/users?limit=${limit}&page=${currentPage.value}`) const parsedResponse = await response.json() users.value = [ ...users.value, ...parsedResponse.data ] totalPages.value = parsedResponse.totalPages } catch(err) { console.log(err) } } // ... code } } </script>
Also, we need to call the fetchUsers methods from the handleScroll method:
<script> export default { setup() { // ...code const handleScroll = () => { if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight) { fetchUsers() } } //...code } } </script>
Now if we try it, the solution will work, but it has a few problems. First, it will always increment the current page and fetch new results even though we don’t have any more left. Secondly, it doesn’t display a loading state to let the user know that we’re fetching the data:

Adding Loading States
To solve the problem, we introducing booleans that track the state of our requests. First, we will add isInitialRequestLoading
which will be true before the first request is sent and then will become and stay false:
<script> // ...code export default { setup() { //...code const limit = 5 const isInitialRequestLoading = ref(true) // ... code onBeforeMount(async () => { await fetchUsers() isInitialRequestLoading.value = false }) // ...code } } </script>

Then, we will add isLoading
which will track subsequent request and display a loading indicator at the bottom of the screen:
<template> <div id="app"> <!-- ...html --> <div v-if="isLoading" class="loading-spinner" > Loading moar users </div> </div> </template> <script> // ...code export default { setup() { // ...code const isInitialRequestLoading = ref(true) const isLoading = ref(false) // ...code const handleScroll = async () => { if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight) { isLoading.value = true await fetchUsers() isLoading.value = false } } // ...code return { users, isInitialRequestLoading, isLoading, } } } </script> <style scoped> /* ...css */ .loading-spinner { padding-bottom: 3rem; } </style>

Finally, to stop loading sending more requests when we don’t have more data, we will create a computed value, called hasLoadedAllData
. It will check if the current page equals the total amount of pages and also checks if we’re not currently loading any more data.
<template> <div id="app"> <!-- ..html --> <div v-if="hasFetchedAllData" class="loading-spinner" > No More Data </div> </div> </template> <script> import { /* ...imports */ computed } from 'vue' export default { setup() { // ...code const hasFetchedAllData = computed(() => { return currentPage.value === totalPages.value && !isLoading.value }) // ...code const handleScroll = async () => { if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight) { console.log(hasFetchedAllData.value || isLoading.value) if (hasFetchedAllData.value || isLoading.value) { return } isLoading.value = true await fetchUsers() isLoading.value = false } } // ...code return { users, isInitialRequestLoading, isLoading, hasFetchedAllData, } } } </script>

Conclusion
The composition API looks very promising. The fact that we can use it outside of the component to create reactive data adds a lot more flexibility to how we develop features.
I write code to solve real-world problems and build business applications as a Full-Stack developer. I enjoy being challenged and develop projects that require me to work outside my comfort and knowledge set. I’m interested in engineering, design, entrepreneurship, and startups. When driven by passion, making stuff is just fun.