Vue 3 Infinite Scroll with the Composition API

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
vue cli generate app

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>
users avatars and names

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:

infinite scroll vue 3

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>
vue 3 infinite scroll loading initial users

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>
vue 3 infinite scroll loading more users

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>
vue 3 inifinite scroll working example

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.