Implementing infinite scroll / lazy loading in Vue 3

Implementing infinite scroll / lazy loading in Vue 3

2 Jun 2023 infinite-loading javascript vue

Here's a working demo of this guide.

When dealing with large datasets there's two options (other than loading the entire dataset, which is rarely desirable):

  • Pagination
  • Lazy loading

In this guide we'll look at how to implement the latter in Vue 3's Composition API, within a single file component (SFC). We'll be loading data from an API into a simple list, and using infinite scroll to the next chunk of it each time.

States 🔗

First of all, let's establish the different states an inifite scrolling mechanism has. We'll use these in our code:

  • "idle" - the starting state, and the state we return to after each time we load data
  • "loading" - an API request is currently underway and has yet to complete
  • "no-more" - loading is complete, and the API indicated there are no more results (because the number of items was lower than the number (limit) we requested).

Component template 🔗

Let's start with the component template.

<template> <!-- status indicator --> <span id='status'>{{loadMoreStatus}}</span> <!-- list --> <ul ref='list'> <li v-for='item in data'>{{item.breed}}</li> </ul> <!-- infinite scroll --> <p v-if='data.length' id='load-more' :class='loadMoreStatus'></p> </template>

The parts to it are:

  • Status indicator - a helper just for this guide, to show the current status of our infinite scroll. It'll be pinned to the top right of the viewport so it's always visible
  • List - the ul we'll load our data into
  • Infinite scroll - the element that will show either a loading spinner or a "no more results" notification

Component JavaScript 🔗

Now for the component JavaScript. First, a few preparatory bits:

//prep import { ref, onMounted } from 'vue'const data = ref([]); const page = ref(1); const limit = 20; const list = ref(null);

We'll need refs for reactive data and the onMounted hook to attach a scroll listener to our list element which populates list (via the element's template ref attribute). page and limit are used to control the offset and limits to our API requests respectively.

limit doesn't need to be a ref, since it never changes (we always want 20 items per request), whereas page will change, as we increment it each request.

Now let's define our data-loading function, which calls the API (we'll use the Catfacts.ninja API, specifically to retrieve cat breeds).

//load data function const loadData = async () => { await new Promise(res => setTimeout(res, 500)); const url = `https://catfact.ninja/breeds? limit=${limit}& page=${page.value}` const req = await fetch(url); const newData = await req.json(); data.value.push(...newData.data); loadMoreStatus.value = newData.data.length < limit ? 'no-more' : 'idle'; page.value++; } //get first set of data loadData();

That should all look fairly simple. We call the API, passing the page and limit, and get back JSON. We push that data into our data container, then update the infinite scroll status to either "idle" or "no-more" depending on whether the API returned equal to or fewer than the number of items we requested, respectively.

Note also that I'm artificially throttling the request, making it take at least half a second (500 miliseconds), just so we can definitely see the visual change of state from idle to loading to done.

Finally, we call our function on enter, to get the first load of data.

Now for the last part of our JavaScript, which listens for scrolling down to the bottom of the list, and triggering another call to our data-loading function.

const loadMoreStatus = ref('idle'); onMounted(() => { let scrollCb; window.addEventListener('scroll', scrollCb = async evt => { if ( loadMoreStatus.value == 'idle' && list.value.getBoundingClientRect().bottom < window.innerHeight ) { loadMoreStatus.value = 'loading'; loadData(); } }); scrollCb(); });

First we create a ref to track the changing state, beginning in "idle" as I said before. Next, once the component has mounted, we listen for scroll and, when the user gets to the bottom of the list, we change to "loading" state and load more data.

We check if the user has got to the bottom of the list by checking against the list element's bounding rectangle.

Note we also assign our scroll callback to a variable, scrollCb, so we can call it straight away, before any scroll. This caters for situations where the user can already see the bottom of the list, even without scrolling. This means either they have a large monitor, or we set a low limit, or both.

Component CSS 🔗

Let's wrap this up with some accompanying CSS.

<style scoped> * { font-family: arial; } /* infinite scroll container */ #load-more { text-align: center; color: #999; } /* loading spinner */ #load-more.loading::before { content: ''; display: inline-block; width: 14px; height: 14px; border: solid 2px gray; animation: spin .25s linear infinite; } @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(180deg); } } /* no more data notif */ #load-more.no-more::before { content: 'No more results'; } /* status helper */ #status { position: fixed; right: 1rem; top: 1rem; background: #f003; padding: .2rem .5rem; border-radius: 4px; } </style>

Nothing too exotic there. For the loading state, we're using a simple CSS animation-generated spinner (we could use something like loading.io instead.)

So that's it! A couple of possible improvements:

  • For brevity, right now I'm not doing error-checking on the API request; obviously you'd want to do that.
  • We could split out the infinite loading part to its own component, separate from the data-loading function and our list element. Although cleaner, that would also add complexity as it has to communicate with the other parts.

Don't forget you can test this guide in the working demo.

Did I help you? Feel free to be amazing and buy me a coffee on Ko-fi!