Abdelrahman Awad Frontend Engineer πŸ€“ @robustastudio | OSS Maintainer πŸ–– | Vue.js Enthusiast πŸ–– | Author of http://vee-validate.netlify.app

Build better higher-order components with Vue 3

7 min read 2053

Vue 3 logo.

Vue 3 is going to be released soon with the introduction of the Composition API. It comes with many changes and improvements to performance.

Higher-order components (HOCs) are components that add certain functionalities to your app declaratively using the template. I believe they will continue to be very relevant even with the introduction of the Composition API.

HOCs always had problems exposing the full power of their functionality, and because they are not that common in most Vue applications, they are often poorly designed and may introduce limitations. This is because the template is just that β€” a template, or a constrained language in which you express some logic. However, in JavaScript or JSX environments, it is much easier to express logic because you have the entirety of JavaScript available for you to use.

What Vue 3 brings to the table is the ability to seamlessly mix and match the expressiveness of JavaScript using the Composition API and the declarative ease of templates.

I’m actively using HOCs in the applications I built for various pieces of logic like network, animations, UI and styling, utilities, and open-source libraries. I have a few tips to share on how to build HOCs, especially with the upcoming Vue 3 Composition API.

The template

Let’s assume the following fetch component. Before we get into how to implement such a component, you should think about how you would be using your component. Then, you need to decide how to implement it. This is similar to TDD but without the tests β€” it’s more like playing around with the concept before it works.

Ideally, that component would use an endpoint and return its result as a scoped slot prop:

<fetch endpoint="/api/users" v-slot="{ data }">
  <div v-if="data">
    <!-- Show the response data -->
  </div>
</fetch>

Now, while this API serves the basic purpose of fetching some data over the network and displaying it, there are a lot of missing things that would be useful to have.

Let’s start with error handling. Ideally, we would like to be able to detect whether a network or a response error was thrown and display some indication of that to the user. Let’s sketch that into our usage snippet:

<fetch endpoint="/api/users" v-slot="{ data, error }">
  <div v-if="data">
    <!-- Show the response data -->
  </div>
  
  <div v-if="error">
    {{ error.message }}
  </div>
</fetch>

So far so good. But what about loading state? If we follow the same path, we end up with something like this:

We made a custom demo for .
No really. Click here to check it out.

<fetch endpoint="/api/users" v-slot="{ data, error, loading }">
  <div v-if="data">
    <!-- Show the response data -->
  </div>
  
  <div v-if="error">
    {{ error.message }}
  </div>
  
  <div v-if="loading">
    Loading....
  </div>
</fetch>

Cool. Now, let’s assume we need to have pagination support:

<fetch endpoint="/api/users" v-slot="{ data, error, loading, nextPage, prevPage }">
  <div v-if="data">
    <!-- Show the response data -->
  </div>
  
  <div v-if="!loading">
    <button @click="prevPage">Prev Page</button>
    <button @click="nextPage">Next Page</button>
  </div>
  
  <div v-if="error">
    {{ error.message }}
  </div>
  
  <div v-if="loading">
    Loading....
  </div>
</fetch>

You see where this is going, right? We are adding way too many properties to our default scoped slot. Instead, let’s break that down into multiple slots:

<fetch endpoint="/api/users">
  <template #default="{ data }">
    <!-- Show the response data -->
  </template>
  
  <template #pagination="{ prevPage, nextPage }">
    <button @click="prevPage">Prev Page</button>
    <button @click="nextPage">Next Page</button>
  </template>
  
  <template #error="{ message }">
    <p>{{ message }}</p>
  </div>
  
  <template #loading>
    Loading....
  </template>
</fetch>

While the number of characters we have is mostly the same, this is much cleaner in the sense that it uses multiple slots to show different UI during the different operation cycles of the component. It even allows us to expose more data on a per-slot basis, rather than the component as a whole.

Of course, there is room for improvement here. But let’s decide that these are the features you want for that component.

Nothing is working yet. You still have to implement the actual code that will get this to work.

Starting with the template, we only have 3 slots that are displayed using v-if:

<template>
  <div>
    <slot v-if="data" :data="data" />
    
    <slot v-if="!loading" name="pagination" v-bind="{ nextPage, prevPage }" />
    
    <slot v-if="error" name="error" :message="error.message" />
    
    <slot v-if="loading" name="loading" />
  </div>
</template>

Using v-if with multiple slots here is an abstraction, so the consumers of this component don’t have to conditionally render their UI. It’s a convenient feature to have in place.

The composition API allows for unique opportunities for building better HOCs, which is what this article is about in the first place.

The JavaScript

With the template out of the way, the first naive implementation will be in a single setup function:

import { ref, onMounted } from 'vue';

export default {
  props: {
    endpoint: {
      type: String,
      required: true,
    }
  },
  setup({ endpoint }) {
    const data = ref(null);
    const loading = ref(true);
    const error = ref(null);
    const currentPage = ref(1);
    
    function fetchData(page = 1) {
      // ...
    }
    
    function nextPage() {
      return fetchData(currentPage.value + 1);
    }
    
    function prevPage() {
      if (currentPage.value <= 1) {
        return;
      }
  
      fetchData(currentPage.value - 1);
    }
    
    
    onMounted(() => {
      fetchData();
    });
    
    return {
      data,
      loading,
      error,
      nextPage,
      prevPage
    };
  }
};

That’s an overview of the setup function. To complete it, we can implement the fetchData function like this:

function fetchData(page = 1) {
  loading.value = true;
  // I prefer to use fetch
  // you cause use axis as an alternative
  return fetch(`${endpoint}?page=${page}`, {
    // maybe add a prop to control this
    method: 'get',
    headers: {
      'content-type': 'application/json'
    }
  })
    .then(res => {
      // a non-200 response code
      if (!res.ok) {
        // create error instance with HTTP status text
        const error = new Error(res.statusText);
        error.json = res.json();
        throw error;
      }

      return res.json();
    })
    .then(json => {
      // set the response data
      data.value = json;
      // set the current page value
      currentPage.value = page;
    })
    .catch(err => {
      error.value = err;
      // incase a custom JSON error response was provided
      if (err.json) {
        return err.json.then(json => {
          // set the JSON response message
          error.value.message = json.message;
        });
      }
    })
    .then(() => {
      // turn off the loading state
      loading.value = false;
    });
}

With all of that in place, the component is ready to be used. You can find a working sample of it here.

However, this HOC component is similar to what you would have in Vue 2. You only re-wrote it using the composition API, which, while neat, is hardly useful.

I’ve found that, to build a better HOC component for Vue 3 (especially a logic oriented component like this one), it is better to build it in a “Composition-API-first” manner. Even if you only plan to ship a HOC.

You will find that we kind of already did that. The fetch component’s setup function can be extracted to its own function, which is called useFetch:

export function useFetch(endpoint) {
  // same code as the setup function
}

And instead our component will look like this:

import { useFetch } from '@/fetch';

export default {
  props: {
   // ...
  },
  setup({ endpoint }) {
      const api = useFetch(endpoint);
      
      return api;
  }
}

This approach allows for a few opportunities. First, it allows us to think about our logic while being completely isolated from the UI. This allows our logic to be expressed fully in JavaScript. It can be hooked later to the UI, which is the fetch component ‘s responsibility.

Secondly, it allows our useFetch function to break down its own logic to smaller functions. Think of it as “grouping” similar stuff together, and maybe creating variations of our components by including and excluding those smaller features.

Breaking it down

Let’s shed light on that by extracting the pagination logic to its own function. The problem becomes: how can we separate the pagination logic from the fetching logic? Both seem intertwined.

You can figure it out by focusing on what the pagination logic does. A fun way to figure it out is by taking it away and checking the code you eliminated.

Currently, what it does is modify the endpoint by appending a page query param, and maintaining the state of the currentPage state while exposing next and previous functions. That is literally what is being done in the previous iteration.

By creating a function called usePagination that only does the part we need, you will get something like this:

import { ref, computed } from 'vue';

export function usePagination(endpoint) {
  const currentPage = ref(1);
  const paginatedEndpoint = computed(() => {
    return `${endpoint}?page=${currentPage.value}`;
  });
  
  function nextPage() {
    currentPage.value++;
  }
  
  function prevPage() {
    if (currentPage.value <= 1) {
      return;
    }
    
    currentPage.value--;    
  }
  
  return {
    endpoint: paginatedEndpoint,
    nextPage,
    prevPage
  };
}

What’s great about this is that we’ve hidden the currentPage ref from outside consumers, which is one of my favorite parts of the Composition API. We can easily hide away non-important details from API consumers.

It’s interesting to update the useFetch to reflect that page, as it seems to need to keep track of the new endpoint exposed by usePagination. Fortunately, watch has us covered.

Instead of expecting the endpoint argument to be a regular string, we can allow it to be a reactive value. This gives us the ability to watch it, and whenever the pagination page changes, it will result in a new endpoint value, triggering a re-fetch.

import { watch, isRef } from 'vue';

export function useFetch(endpoint) {
  // ...
  function fetchData() {
    // ...
    
    // If it's a ref, get its value
    // otherwise use it directly
      return fetch(isRef(endpoint) ? endpoint.value : endpoint, {
        // Same fetch opts
      }) // ...
  }
  
  // watch the endpoint if its a ref/computed value
  if (isRef(endpoint)) {
    watch(endpoint, () => {
      // refetch the data again
      fetchData();
    });
  } 
  
  return {
    // ...
  };
}

Notice that useFetch and usePagination are completely unaware of each other, and both are implemented as if the other doesn’t exist. This allows for greater flexibility in our HOC.

You’ll also notice that by building for Composition API first, we created blind JavaScript that is not aware of your UI. In my experience, this is very helpful for modeling data properly without thinking about UI or letting the UI dictate the data model.

Another cool thing is that we can create two different variants of our HOC: one that allows for pagination and one that doesn’t. This saves us a few kilobytes.

Here is an example of one that only does fetching:

import { useFetch } from '@/fetch';

export default {
  setup({ endpoint }) {
    return useFetch(endpoint);
  }
};

Here is another that does both:

import { useFetch, usePagination } from '@/fetch';

export default {
  setup(props) {
    const { endpoint, nextPage, prevPage } = usePagination(props.endpoint);
    const api = useFetch(endpoint);
    
    return {
      ...api,
      nextPage,
      prevPage
    };
  }
};

Even better, you can conditionally apply the usePagination feature based on a prop for greater flexibility:

import { useFetch, usePagination } from '@/fetch';

export default {
  props: {
      endpoint: String,
      paginate: Boolean
  },
  setup({ paginate, endpoint }) {
    // an object to dump any conditional APIs we may have
    let addonAPI = {};

    // only use the pagination API if requested by a prop
    if (paginate) {
      const pagination = usePagination(endpoint);
      endpoint = pagination.endpoint;
      addonAPI = {
        ...addonAPI,
        nextPage: pagination.nextPage,
        prevPage: pagination.prevPage
      };
    }

    const coreAPI = useFetch(endpoint);
    
    // Merge both APIs
    return {
      ...addonAPI,
      ...coreAPI,
    };
  }
};

This could be too much for your needs, but it allows your HOCs to be more flexible. Otherwise, they would be a rigid body of code that’s harder to maintain. It’s also definitely more unit-test friendly.

Here is the end result in action:

elated-spence-tinnn

elated-spence-tinnn by logaretm using vue

Conclusion

To sum it all up, build your HOCs as Composition API first. Then, break the logical parts down as much as possible into smaller composable functions. Compose them all in your HOCs to to expose the end result.

This approach allows you to build variants of your components, or even one that does it all without being fragile and hard to maintain. By building with a composition-API-first mindset, you allow yourself to write isolated parts of code that are not concerned with UI. In this way, you let your HOC be the bridge between blind JavaScript and functionless UI.

Experience your Vue apps exactly how a user does

Debugging Vue.js applications can be difficult, especially when there are dozens, if not hundreds of mutations during a user session. If you’re interested in monitoring and tracking Vue mutations for all of your users in production, try LogRocket. https://logrocket.com/signup/

LogRocket is like a DVR for web apps, recording literally everything that happens in your Vue apps including network requests, JavaScript errors, performance problems, and much more. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred.

The LogRocket Vuex plugin logs Vuex mutations to the LogRocket console, giving you context around what led to an error, and what state the application was in when an issue occurred.

Modernize how you debug your Vue apps - .

Abdelrahman Awad Frontend Engineer πŸ€“ @robustastudio | OSS Maintainer πŸ–– | Vue.js Enthusiast πŸ–– | Author of http://vee-validate.netlify.app

One Reply to “Build better higher-order components with Vue 3”

Leave a Reply