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.
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:
<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.
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.
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:
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.
Debugging Vue.js applications can be difficult, especially when users experience issues that are difficult to reproduce. If youβre interested in monitoring and tracking Vue mutations and actions for all of your users in production, try LogRocket.
LogRocket lets you replay user sessions, eliminating guesswork by showing exactly what users experienced. It captures console logs, errors, network requests, and pixel-perfect DOM recordings β compatible with all frameworks.
With Galileo AI, you can instantly identify and explain user struggles with automated monitoring of your entire product experience.
Modernize how you debug your Vue apps β start monitoring for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocketβs Content Advisory Board. Youβll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild a secure file upload system using Astroβs server-side rendering, Cloudinaryβs SDKs, and native integration.
useSearchParams
in ReactLearn about React Routerβs useSearchParams Hook, and how it helps you manage state through the URL for a more resilient, user-friendly app.
Discover whatβs new in Node.js 24, including major features, improvements, and how to prepare your projects.
Build agentic AI workflows with Ollama and React using local LLMs for enhanced privacy, reduced costs, and offline performance.
2 Replies to "Build better higher-order components with Vue 3"
I submitted and created the string to code
This looks like hooks instead of hoc