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:
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 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.
LogRocket is like a DVR for web and mobile 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 β 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 nowIn web development projects, developers typically create user interface elements with standard DOM elements. Sometimes, web developers need to create […]
Toast notifications are messages that appear on the screen to provide feedback to users. When users interact with the user […]
Deno’s features and built-in TypeScript support make it appealing for developers seeking a secure and streamlined development experience.
It can be difficult to choose between types and interfaces in TypeScript, but in this post, you’ll learn which to use in specific use cases.
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