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 Replay is a weekly newsletter for dev and engineering leaders.
Delivered once a week, it's your curated guide to the most important conversations around frontend dev, emerging AI tools, and the state of modern software.
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:
elated-spence-tinnn
No Description
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.

Vibe coding isnât just AI-assisted chaos. Hereâs how to avoid insecure, unreadable code and turn your âvibesâ into real developer productivity.

GitHub SpecKit brings structure to AI-assisted coding with a spec-driven workflow. Learn how to build a consistent, React-based project guided by clear specs and plans.

:has(), with examplesThe CSS :has() pseudo-class is a powerful new feature that lets you style parents, siblings, and more â writing cleaner, more dynamic CSS with less JavaScript.

Kombai AI converts Figma designs into clean, responsive frontend code. It helps developers build production-ready UIs faster while keeping design accuracy and code quality intact.
Hey there, want to help make our blog better?
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 now
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