DEV Community

Cover image for Vue.js Pattern for Async Requests: Using Renderless Components
Lukas Hermann
Lukas Hermann

Posted on • Edited on • Originally published at tentmaker.dev

Vue.js Pattern for Async Requests: Using Renderless Components

Most Vue apps need asynchronous HTTP requests and there are many ways to realize them: in the mounted() lifecycle hook, in a method triggered by a button, within the store (when using vuex) or in the asyncData() and fetch() methods (with Nuxt).

While a simple request is very easy with axios, we usually want to cover at least two additional states:

  1. Show something to the user while the request is pending
  2. Handle errors gracefully

Handling these states adds additional code and can quickly lead to code-duplication when having to implement many different requests.

Contents

  1. Origin of the Idea
  2. HTTP requests: A typical example
  3. The Async Renderless Component

To cut right to the meat, jump to The Async Renderless Component.

Note: Axios is used to make HTTP requests in this example, but it works just as well with any other library for AJAX requests. Also, this example uses this wonderful free Dog API: https://dog.ceo/dog-api/ 🐶.

Origin of the Idea

The idea is not my own, but borrowed from Vue.js creator Evan You @youyuxi who voiced it secondarily while talking about Advanced Vue Components with Adam Whatan on the Full Stack Radio Podcast during Episode 81.

HTTP request in Vue Components: A typical example

Let's start with a minimal example to request a random dog image. The mounted() hook contains the axios call which populates the image variable.

Vue.component("example", {
  el: "#example",
  data() {
    return {
      image: null
    };
  },
  mounted() {
    axios
      .get("https://dog.ceo/api/breeds/image/random")
      .then(function(response) {
        this.image = response.data;
      });
  }
});

Simple enough. However, we want to show a loading animation and handle request errors. So in addition to the image variable pending: false and error: null are added. The mounted() hook then looks as follows:

Vue.component("example", {
  [...]
  mounted() {
    this.pending = true;
    axios
      .get("https://dog.ceo/api/breeds/image/random")
      .then(function(response) { this.image = response.data })
      .catch(function(error) { this.error = error })
      .finally(function () { this.pending = false });
  }
});

Now a loading indicator can be shown for pending === true and a basic error message can be displayed if error !== null. It's really simple, but it can get tedious to implement this pending/success/error behavior repeatedly. Besides, if the request contains parameters that can be changed by the user, e.g. filters or sorting options, then the request has to move to a method which has to be called, whenever the parameters changes, to reload the data.

One easy and effective way to abstract away this simple behavior and make it reusable is ...

The Async Renderless Component

This component makes use of the incredibly versatile Scoped Slot feature. A slot is any piece of HTML that can be passed to a component, telling the component: "Here, render this somewhere". With scoped slots the component which receives the HTML snipped answers: "Awesome, I will put your HTML right there. And here is some data you can use with your snipped if you like".

The Async Renderless component is just such a component that receives a snippet of HTML, a URL and parameters and answers: "Hey look, I am requesting this data for you, here is data, pending and error for you to use."

The Async Renderless Component in full:

Vue.component("async", {
  props: {
    url: { type: String, default: "", required: true },
    params: { type: Object, default: () => ({}) }
  },
  data() {
    return {
      pending: true,
      error: false,
      data: null
    };
  },
  watch: {
    url() {
      this.requestData();
    },
    params: {
      handler() {
        this.requestData();
      },
      deep: true
    }
  },
  mounted() {
    this.requestData();
  },
  methods: {
    async requestData() {
      this.pending = true;
      try {
        const { data } = await axios.get(this.url, { params: this.params });
        this.data = data;
        this.error = false;
      } catch (e) {
        this.data = null;
        this.error = e;
      }
      this.pending = false;
    }
  },
  render() {
    return this.$scopedSlots.default({
      pending: this.pending,
      error: this.error,
      data: this.data
    });
  }
});

Note: I am using some javascript magic here: Arrow Functions, Async/Await and try...catch.

The "renderless" happens in the render() tag. Instead of an HTML tag, these components only renders the HTML snippet it receives in its slot as scoped slot, passing three data points to it: pending, error and data.

The watch functions make sure that the data is reloaded whenever either url or params change.

We use the async component inside our template like this:

<async url="https://dog.ceo/api/breed/husky/images">
  <template v-slot:default="{ pending, error, data }">
    <div v-if="pending">Loading ...</div>
    <div v-else-if="error">{{ error }}</div>
    <div v-else>{{ data }}</div>
  </template>
</async>

Why a renderless component and not a mixin or directive?

Components are not the only way to reuse code in Vue, another way is to use a Mixin or a Custom Directive. Both are fine ways to solve this problem. Renderless components utilizing scoped slots are operating the way Vue wants to work, it can be imported when needed just like you are used to with any other component. Thus it's a very explicit way to reuse code as opposed to mixins or directives which don't have to be included separately. In the end, it comes down to preference.

An applied example

I constantly find myself implementing lists when working with APIs which usually feature things like pagination, filters, sorting and search. So I decided to put together a "real-life" example which renders a simple list of dog images with a very simple filter option for some different breeds (and a wrong API call to see the error state):

Whenever one of the filter buttons is clicked the URL, which is passed to the async component, is updated with the appropriate breed. The async component takes care of the HTTP request. No more HTTP request logic is needed in the parent component, separation of concerns is obeyed, our minds are freed and the universe is in harmony 😄.

Top comments (20)

Collapse
 
sammerro profile image
Michał Kowalski

Hey, I wanted to introduce this in project in my work. Is this solution working well with post, delete and put requests? My project is really complex. Does it scale well?

Collapse
 
maoberlehner profile image
Markus Oberlehner

You might be interested in my article about this very same topic (funnily enough also inspired by the same podcast episode): Building Renderless Components to Handle CRUD Operations in Vue.js

Collapse
 
tiagosmartinho profile image
Tiago Martinho

Whoooa, good solution.

I am trying to reproduce your solution and a question has come up. my goal is to pass data torouter-view, I am now passing as props, but I do not know if it is the best solution?

On each page I have a variable corresponding to the page that will work this data.

Do you have any suggestions?

question

Thread Thread
 
maoberlehner profile image
Markus Oberlehner

Passing props to <router-view> is fine in my opinion!

Thread Thread
 
tiagosmartinho profile image
Tiago Martinho

ty :p

a problem occured. On some pages, the props data is expected to be an object, but on others it is an array.

I thought I'd give the property multiple types, or is there a better solution?

Solution

props: {
    data: Object | Array
},
Thread Thread
 
maoberlehner profile image
Markus Oberlehner

LGTM ;)

Collapse
 
sammerro profile image
Michał Kowalski

I read your article several months ago. Great content. I even sent the link to my coworkers! Although I was affraid to introduce it to our code.
Now I have found another post and started to think about it more seriously.
But probably I will use it in a new project.

Collapse
 
lhermann profile image
Lukas Hermann

This is awesome and a really smart solution! Have you tried this with server-side form validation?

Thread Thread
 
maoberlehner profile image
Markus Oberlehner

Thanks! No, haven't used this extensively myself yet.

Collapse
 
lhermann profile image
Lukas Hermann

I am only using it with get requests. post and put tend to have additional validation steps which I usually do the regular way.
As for scaling: using the renderless component with get requests scales exceptionally well. It works just as well for small components as for really large lists with many parameters and changing options.

Collapse
 
sammerro profile image
Michał Kowalski

Thank you for the response!

Collapse
 
sarutole profile image
SaruTole

You might want to use multiple words for a component name.

This recommendation is considered essential as a means to prevent conflicts with existing and future HTML elements (since all HTML elements are a single word):
vuejs.org/v2/style-guide/#Multi-wo...

Collapse
 
eladc profile image
Elad Cohen

Great article. Thanks!

Just one point - I think we should start to use Vue.js Function API to wrap this logic as described here:
blog.bitsrc.io/vue-js-3-future-ori...

Collapse
 
lhermann profile image
Lukas Hermann

Thanks! As soon as the first early version of Vue 3 is available I will update this article with the new syntax.

Collapse
 
edimeri profile image
Erkand Imeri

Great one. Thanks Lukas.

Collapse
 
lhermann profile image
Lukas Hermann

Thanks :)

Collapse
 
webdeasy profile image
webdeasy.de

Great article, good job!

Collapse
 
tiagosmartinho profile image
Tiago Martinho

Good article :p

Collapse
 
kenafh profile image
Ken

Lukas,

Great article, I have a quick question. You advocate doing your API calls in the mounted() hook. I've always read that you should kick off async stuff in created(). Your thoughts?

KLC

Collapse
 
lhermann profile image
Lukas Hermann

Hi Ken,

in fact, I don't know myself which one is better, mounted or created hook. I pretty much picked one at random :)