Advanced Data Fetching with Nuxt 3

Part 9 of 9 in Upgrading Nuxt 2 to Nuxt 3
Dan Pastori avatar
Dan Pastori January 26th, 2023

Working with ROAST and Bugflow, both having a Nuxt 3 frontend, I’ve come across a lot of scenarios where I’ve had to do some more advanced data fetching with Nuxt 3 and the provided composables. I’ve written a basic article about using asyncData() in Nuxt 3. This article will be extending on the previous article and covering some more advanced scenarios like automatic watch sources, infinite scrolling, pagination and tips for dealing with multiple async data sources. Let’s get started!

Nuxt 3 Watch Sources with useAsyncData()

To be honest, this is my favorite feature of any of the new Nuxt 3 data fetching composables. Watch sources works with useFetch() and useAsyncData() along with their lazy counterparts. Let’s set up a use case so we can explain how wild this is.

Say you have a page that has a variety of filters, settings, parameters, etc used to query an API. In ROAST this would be like the search page or in Bugflow, our bug listing page. These pages allow the user to set their parameters, filters, etc. and call the API to get data that matches what they are looking for. Every time the user updates one of these filters, you will have to re-query the API. Normally, this would be done by calling a function, or building a reactive query string.

However, with watch sources, this is much easier! Watch sources allow you to “watch reactive sources to auto-refresh”. What does that mean? That means when the user changes a parameter you used in your query, the data auto refreshes. It’s amazing! Let’s look at the following code:

<template>
  <div>
    <ul>
      <li v-for="cafe in cafes.data" 
        :key="cafe.id" 
        v-text="cafe.company.name+' - '+cafe.location_name"></li>
    </ul>
  </div>
</template>
<script setup>
const search = ref('');
const page = ref(1);

const { data: cafes, error } = await useAsyncData(
  'cafes',
  () => $fetch( `/api/v1/cafes`, {
    method: 'GET',
    baseURL: 'https://api.roastandbrew.coffee',
    params: {
      page: page.value,
      search: search.value,
    }
  } ), {
    watch: [
      page,
      search
    ]
  }
);
</script>

Before we start breaking this apart, I’m using useAsyncData() but watch sources work with all the new data fetching composables.

In this example we are loading the first set of cafes from the https://api.roastandbrew.coffee/api/v1/cafes endpoint. This is a paginated resource and we can search cafes to get the find the ones we are looking for. So we set up the following filters:

const search = ref('');
const page = ref(1);

Next, we set up our data fetching request and pass these two parameters in the params section:

const { data: cafes, error } = await useAsyncData(
  'cafes',
  () => $fetch( `/api/v1/cafes`, {
    // ... other options
    params: {
      page: page.value,
      search: search.value,
    }
  } ), {
    watch: [
      page,
      search
    ]
  }
);

Looks pretty familiar so far! However, the magic comes in the third parameter to the useAsyncData() composable and that’s the watch key. What this does is allows us to pass an array of reactive sources that will re-query when changed.

Let’s start with page where we want to add simple next and previous pagination. When the user increments or decrements the page value, we want to refresh the data source with the next paginated set of data. Instead of calling refresh (a method in the composable) or dynamically computing the query string, we can automatically load the new data instantly in Nuxt 3 when the watch source changes. Add the following methods:

<script setup>
//... Other settings and async data

const previous = () => {
  if( page.value != 1 ){
    page.value = page.value -1 ;
  }
}

const next = () => {
  if( page.value + 1 <= cafes.value.last_page ){
    page.value = page.value + 1;
  }
}
</script>

Notice how these methods don’t explicitly call a refresh or another method to reload the data? That’s because page is one of the watch sources defined. All you have to do is increment or decrement the page value. This will automatically refresh the data! Super convenient for dynamic data fetching with Nuxt 3 and implementing searches, pagination, or other filters.

The location variable works the same way. Once it changes, a new request to load the data will be made. However, you will probably want to debounce the input if it’s a text search or you will send way too many API requests and blow through the throttling!

Here’s our final code example:

<template>
  <div>
    <ul>
      <li v-for="cafe in cafes.data" 
        :key="cafe.id" 
        v-text="cafe.company.name+' - '+cafe.location_name"></li>
    </ul>

    <button @click="previous()" v-if="page > 1">Previous</button>
    <button @click="next()" v-if="page < cafes.last_page">Next</button>
  </div>
</template>
<script setup>
const search = ref('');
const page = ref(1);

const { data: cafes, error } = await useAsyncData(
  'cafes',
  () => $fetch( `/api/v1/cafes`, {
    method: 'GET',
    baseURL: 'https://api.roastandbrew.coffee',
    params: {
      page: page.value,
      search: search.value,
    }
  } ), {
    watch: [
      page,
      search
    ]
  }
);

const previous = () => {
  if( page.value != 1 ){
    page.value = page.value -1 ;
  }
}

const next = () => {
  if( page.value + 1 <= cafes.value.last_page ){
    page.value = page.value + 1;
  }
}
</script>

I really love how the watch sources clean up the code base and make the experience feel so much more optimized and dynamic. Let’s touch on another advanced data fetching scenario, infinite scrolling, or “compounding/appending” requests.

Append Data from $fetch in Nuxt 3

Specifically in Bugflow, we ran into a scenario where we wanted a “compounding” or “infinite scroll” type scenario. We had a bug listing screen where the user can see all bugs on a project, newest to oldest. As they scrolled, they had the option to load more.

In this scenario we needed to keep appending the data returned from a data fetch with Nuxt 3 in order to show it all on one screen. For this scenario, I recommend using the $fetch method that’s globally available to directly call the API. Why not a composable? You can, but the composables provided want to replace the data every request. That’s how they are designed. We want to append the data. Take a look at the code:

const page = ref(1);
const lastPage = ref(1);
const companies = ref([]);
const pending = ref(false);

onMounted(() => {
  loadCompanies();
})

const loadMore = () => {
  if( page.value + 1 <= lastPage.value ){
    page.value = page.value + 1;
    
    loadCompanies();
  }
}

const loadCompanies = () => {
  pending.value = true;

  $fetch(`/api/v1/companies`, {
    method: 'GET',
    baseURL: 'https://api.roastandbrew.coffee',
    params: {
      page: page.value
    }
  }).then( function( companies ){
    appendCompanies( companies.data );
    pending.value = false;
    lastPage.value = companies.last_page;
  });
}

const appendCompanies = ( newCompanies ) => {
  newCompanies.forEach( ( company ) => {
    companies.value.push( company );
  });
}

As you can see, the code is a little bit more verbose than the elegant way you’d typically load data with useFetch() or useAsyncData(). However, the power is there. Let’s start at the top:

const page = ref(1);
const lastPage = ref(1);
const companies = ref([]);
const pending = ref(false);

Right away we declare 4 variables, page, lastPage, companies, and pending. Since we are loading a paginated resource, we keep track of the page (current page we are on) and the lastPage(the final page of results for the resource). We also implement our own simple pending state while more data loads. If you were using the useAsyncData() composable, this would already be available for you.

Let’s jump to our loadCompanies() method:

const loadCompanies = () => {
  pending.value = true;

  $fetch(`/api/v1/companies`, {
    method: 'GET',
    baseURL: 'https://api.roastandbrew.coffee',
    params: {
      page: page.value
    }
  }).then( function( companies ){
    appendCompanies( companies.data );
    pending.value = false;
    lastPage.value = companies.last_page;
  });
}

What this does is first, set the pending value to true. This allows us to display a loader or handle other events to show the user the data is loading.

Next we call $fetch on our endpoint and pass the page param. This will grab the current paginated chunk of companies from our API. Most importantly, we listen to the successful return of the promise with .then()callback.

.then( function( companies ){
    appendCompanies( companies.data );
    pending.value = false;
    lastPage.value = companies.last_page;
});

Upon success, we take the response, append the new companies to the local reactive companies array, set pending to false, and save the last page so we know when to not load any more. The appendCompanies() method is the guts of our “compounding” or “infinite scrolling” takes place:

const appendCompanies = ( newCompanies ) => {
  newCompanies.forEach( ( company ) => {
    companies.value.push( company );
  });
}

This simple method takes the new companies, iterates over them, and appends them to the local companies array which is reactive. We can then display the reactive companies in our template like:

<template>
  <div>
    <h2>Companies</h2>
    <ul>
      <li v-for="company in companies" 
        :key="company.id" 
        v-text="company.name"></li>
    </ul>
  </div>
</template>

Finally, we have our loadMore() method. This method simply increments our page number and calls the loadCompanies() method. Unlike in the first section, using a watch source, we aren’t using a composable so we have to call the method ourselves. The loadMore() method looks like:

const loadMore = () => {
  if( page.value + 1 <= lastPage.value ){
    page.value = page.value + 1;
    
    loadCompanies();
  }
}

For the sake of thoroughness, I also initially call the loadCompanies() method with the onMounted() hook. You don’t have to if you want to pre-populate your page on the server side.

Our final implementation should look like:

<template>
  <div>
    <h2>Companies</h2>
    <ul>
      <li v-for="company in companies" 
        :key="company.id" 
        v-text="company.name"></li>
    </ul>

    <div v-if="pending">Loading...</div>
    <button @click="loadMore()">Load more</button>
  </div>
</template>

<script setup>
const page = ref(1);
const lastPage = ref(1);
const companies = ref([]);
const pending = ref(false);

onMounted(() => {
  loadCompanies();
})

const loadMore = () => {
  if( page.value + 1 <= lastPage.value ){
    page.value = page.value + 1;
    
    loadCompanies();
  }
}

const loadCompanies = () => {
  pending.value = true;

  $fetch(`/api/v1/companies`, {
    method: 'GET',
    baseURL: 'https://api.roastandbrew.coffee',
    params: {
      page: page.value
    }
  }).then( function( companies ){
    appendCompanies( companies.data );
    pending.value = false;
    lastPage.value = companies.last_page;
  });
}

const appendCompanies = ( newCompanies ) => {
  newCompanies.forEach( ( company ) => {
    companies.value.push( company );
  });
}
</script>

You can implement this in a component or in a page itself. We implemented it in a table listing on Bugflow. The user initially sees the newest bugs, but can view more as they scroll down.

I’ve also mentioned “infinite” scrolling, but as you can see in the template, I have a button that calls the loadMore() method. However, there is a simple VueUse method where you can check if an element, such as an “end of list” element, is visible and then call loadMore(). And just like that you have infinite scrolling! Check out useElementVisibility() for more info.

Helpful Nuxt 3 Data Fetching Hints

Here are a few hints that can help you when you make more advanced data fetching requests with Nuxt 3.

Renaming the Refresh Method in Nuxt 3

As your app grows, you will no doubt hit a time where you will have to do multiple asyncData() requests on the same page. Let’s look at loading a few companies and cafes on the same page from the ROAST API:

<script setup>
const search = ref('');

const { data: cafes } = await useAsyncData(
  'cafes',
  () => $fetch( `/api/v1/cafes`, {
    method: 'GET',
    baseURL: 'https://api.roastandbrew.coffee',
    params: {
      search: search.value,
    }
  } )
);

const { data: companies } = await useAsyncData(
  'companies',
  () => $fetch( `/api/v1/companies`, {
    method: 'GET',
    baseURL: 'https://api.roastandbrew.coffee',
    params: {
      search: search.value,
    }
  } )
);
</script>

Note: You can also do simultaneous asyncData() requests like I went through here. Let’s just use the above code as an example for now. It will work great right away. But what if you need to actually call the refresh() method provided by the composable for each data source. When destructuring refresh() from the composable, you will have two methods with the same name. This will not work!

To rename the destructured refresh() method, simply destructure each as follows:

<script setup>
const { data: cafes, refresh: refreshCafes } = await useAsyncData(
  'cafes',
  () => $fetch( `/api/v1/cafes`, {
    method: 'GET',
    baseURL: 'https://api.roastandbrew.coffee',
    params: {
      search: search.value,
    }
  } )
);

const { data: companies, refresh: refreshCompanies } = await useAsyncData(
  'companies',
  () => $fetch( `/api/v1/companies`, {
    method: 'GET',
    baseURL: 'https://api.roastandbrew.coffee',
    params: {
      search: search.value,
    }
  } )
);
</script>

Now you can call refreshCafes() and refreshCompanies() when you need to!

When to use refresh() vs a Watch Source?

The simple answer, use refresh() when you know data on the server side has changed you need to reload the data on the client side. Use a watch source when the user changes parameters that need to be sent to the server.

For example, let’s say you have a list of companies. A user deletes a company. This will not change a query parameter, but the data on the server side will change. Run refresh() and you will have accurate data.

If you want to filter results from the API via a search parameter, set that search parameter as a watch source. When a user changes their query, fresh and accurate data will be re-loaded from the API. Quick note, probably want to use a debounce method from VueUse so you don’t query the API on every keystroke or you will hit a throttle limit in no time!

Conclusion

Hope this helps with your advanced data fetching in Nuxt 3! If you have any questions, feel free to hit me up on Twitter or in our Discord!

If you want to see how these pieces fit into the scope of an entire application, we have a book available. With the purchase of the complete package you can see the entire source code behind ROAST.

Support future content

The Ultimate Guide to Building APIs and Single-Page Applications with Laravel + VueJS + Capacitor book cover.

Psst... any earnings that we make off of our book is being reinvested to bringing you more content. If you like what you read, consider getting our book or get sweet perks by becoming a sponsor.

Written By Dan

Dan Pastori avatar Dan Pastori

Builder, creator, and maker. Dan Pastori is a Laravel certified developer with over 10 years experience in full stack development. When you aren't finding Dan exploring new techniques in programming, catch him at the beach or hiking in the National Parks.

Like this? Subscribe

We're privacy advocates. We will never spam you and we only want to send you emails that you actually want to receive. One-click unsubscribes are instantly honored.