Extending Vue.js Components
Anthony Gore | June 11th, 2017 (Updated March 2nd, 2020) | 9 min read
What's the best way to "extend" a Vue component, i.e. use one component as the basis for other components?
Doing this could save you from duplicating code, making your components quicker to develop and easier to maintain.
There are a number of APIs and patterns that Vue offers for this, and you'll need to choose the right one depending both on your goals and personal taste.
In this article, I'll give you an overview of the different options to help you choose the best and most suitable for your scenario.
Table of contents:
Do you really need to extend your component?
Keep in mind that all methods of extending components can add complexity and verbosity to your code, and in some cases, additional performance overhead.
So before you decide to extend a component, it's a good idea to first check if there are simpler design patterns that can achieve what you want.
The following component design patterns can often be sufficient substitutes for extending a component:
- Props-driven template logic
- Slots
- JavaScript utility functions
Let's do our due diligence by briefly reviewing these.
Props-driven template logic
The easiest way to make a component multi-use, and thus avoid extending it, is to provide a prop which drives conditional logic in the template.
In the following example, we use a prop type
for this purpose.
MyVersatileComponent.vue
<template>
<div class="wrapper">
<div v-if="type === 'a'">...</div>
<div v-else-if="type === 'b'">...</div>
<!--etc etc-->
</div>
</template>
<script>
export default {
props: { type: String },
...
}
</script>
A parent can then declare this component and use the prop to get the variation required.
ParentComponent.vue
<template>
<MyVersatileComponent type="a" />
<MyVersatileComponent type="b" />
</template>
Here are two indicators that you've either hit the limits of this pattern or are misusing it:
- The component composition model makes an app scalable by breaking down the state and logic into atomic parts. If you have too many variations in the one component (a "mega component") you'll find it won't be readable or maintainable.
- Props and template logic are intended to make a component dynamic but come at a runtime resource cost. It's an anti-pattern if you're using this mechanism to solve a code composition problem at runtime.
Slots
Another way to make a component versatile without extending it is to allow the parent component to set custom content within the child using slots.
MyVersatileComponent.vue
<template>
<div class="wrapper">
<h3>Common markup</div>
<slot />
</div>
</template>
ParentComponent.vue
<template>
<MyVersatileComponent>
<h4>Inserting into the slot</h4>
</MyVersatileComponent>
</template>
Renders as:
<div class="wrapper">
<h3>Common markup</div>
<h4>Inserting into the slot</h4>
</div>
A potential limitation of this pattern is that the elements in the slot belong to the parent's context, which may not be a natural way of dividing your logic and state.
Scoped slots can bring more flexibility, and we'll explore these more in the section on renderless components.
JavaScript utility functions
If you only need to reuse standalone functions across your components, you can simply extract these into JavaScript modules without any need to use an extending pattern.
JavaScript's module system is a very flexible and robust way of sharing code, so you should lean on it where possible.
MyUtilityFunction.js
export default function () {
...
}
MyComponent.vue
import MyUtilityFunction from "./MyUtilityFunction";
export default {
methods: {
MyUtilityFunction
}
}
Patterns for extending components
Okay, so you've considered the simpler design patterns above, and none of these are flexible enough for what you need. It's time to consider extending your component.
The four most popular methods for extending Vue component you should be aware of are:
Each of these has its pros and cons and will be more or less suitable depending on the situation in which you want to use them.
Composition functions
The state-of-the-art way to share state and logic between components is the Composition API. This API is included in Vue 3, or available as a plugin in Vue 2.
Rather than defining your component with properties on the component definition object e.g. data
, computed
, methods
, etc, the Composition API allows you to instead create a setup
function where you declare and return these.
For example, here's how you might declare a simple Counter component with classic Vue 2 option properties:
Counter.vue
<template>
<button @click="increment">
Count is: {{ count }}, double is: {{ double }}
</button>
<template>
<script>
export default {
data: () => ({
count: 0
}),
methods: {
increment() {
this.count++;
}
},
computed: {
double () {
return this.count * 2;
}
}
}
</script>
Now, here's the same component refactored to use the Composition API. Note that the functionality is unchanged.
Counter.vue
<template><!--as above--><template>
<script>
import { reactive, computed } from "vue";
export default {
setup() {
const state = reactive({
count: 0,
double: computed(() => state.count * 2)
});
function increment() {
state.count++
}
return {
count,
double,
increment
}
}
}
</script>
One of the main benefits of declaring a component using the Composition API is that it makes logic reuse and extraction very easy.
In a further refactor, I've now moved the counter feature into a JavaScript module useCounter.js
:
useCounter.js
import { reactive, computed } from "vue";
export default function {
const state = reactive({
count: 0,
double: computed(() => state.count * 2)
});
function increment() {
state.count++
}
return {
count,
double,
increment
}
}
Now the counter feature can be seamlessly introduced into any Vue component using it's setup
function:
MyComponent.vue
<template><!--as above--></template>
<script>
import useCounter from "./useCounter";
export default {
setup() {
const { count, double, increment } = useCounter();
return {
count,
double,
increment
}
}
}
</script>
Composition functions are the most straightforward and cost-free way of extending a component by making its features modular and reusable.
Downsides to the Composition API
The disadvantages of the Composition API are only superficial - it can be slightly more verbose and uses syntax that may be unfamiliar to some Vue users.
If you'd like to read more about the pros and cons of the Composition API I suggest my article When To Use The New Vue Composition API (And When Not To).
Mixins
If you're using Vue 2 or simply prefer to organize your component features by option, you can use the mixin pattern. Here, we extract any shared logic and state into a separate object that gets merged with the definition object of a consuming component.
Let's continue with the Counter example that we used in the previous section. This time, I've extracted the shared logic and state into a JavaScript module called CounterMixin.js.
CounterMixin.js
export default {
data: () => ({
count: 0
}),
methods: {
increment() {
this.count++;
}
},
computed: {
double () {
return this.count * 2;
}
}
}
To use the mixin, a consuming component can simply import the module and add it to the mixins
array in its definition. When this component gets instantiated the mixin object is merged with the definition.
MyComponent.vue
import CounterMixin from "./CounterMixin";
export default {
mixins: [CounterMixin],
methods: {
decrement() {
this.count--;
}
}
}
Options merging
What happens when the consuming component has a local option with the same name as one from the mixin?
For example, let's say we added a local increment
method? Which would take precedence?
MyComponent.vue
import CounterMixin from "./CounterMixin";
export default {
mixins: [CounterMixin],
methods: {
// does the local `increment`` method override
// the mixin `increment` method?
increment() { ... }
}
}
This is where the merge strategy comes into play. This is the set of rules to determine what happens when a component contains multiple options of the same name.
Normally, local options will override mixin options. This is not always the case, though. For example, if you have multiple lifecycle hooks of the same type, these will be added to an array of hooks and all will be called sequentially.
You can change this behavior by using a custom merge strategy.
Downsides of mixins
As a pattern for extending components mixins work well for simple cases but will become problematic as you scale. Not only are there naming collisions to watch out for (these are much more problematic when you introduce third-party mixins) but since consuming components don't explicitly state the source of mixin properties, it's often hard to understand what a component does and how it works, especially when using multiple mixins.
Higher-order components
The higher-order component pattern (HOC) is borrowed from the React world but can be used with Vue.
To understand the concept, let's first forget about components and imagine we had two simple JavaScript functions, increment
and double
.
function increment(x) {
return x++;
}
function double(x) {
return x * 2;
}
Say we wanted to add a feature to both of these functions - the ability to log to the console.
To do this, we're going to use the higher-order function pattern where we create a new function addLogging
which accepts a function as an argument and returns a new function with the feature attached.
function addLogging(fn) {
return function(x) {
const result = fn(x);
console.log("The result is: ", result);
return result;
};
}
const incrementWithLogging = addLogging(increment);
const doubleWithLogging = addLogging(double);
Applying the pattern to components
Let's see how to apply this pattern to components by adding a decrement
method to the Counter component.
To do this, we'll create a higher-order component that will render Counter, and at the same time add the decrement
method as an instance property.
The actual code for doing this is complicated, so I've just presented a pseudo-code version to give you the idea.
For a proper implementation see this thread on GitHub.
import Counter from "./Counter";
// psuedo code
const CounterWithDecrement => ({
render(createElement) {
const options = {
decrement() {
this.count--;
}
}
return createElement(Counter, options);
}
});
While the HOC pattern is clean and more scalable than mixins, it adds the overhead of an additional wrapper component and is tricky to implement.
Renderless components
If you want to have the same logic and state across multiple components, only display it differently, consider the renderless component pattern.
When using this pattern we have two types of components - logic components that declare the logic and state, and presentation components for display.
Logic components
Let's again return to our Counter example and imagine that we wanted to reuse this component across several apps but display it differently each time.
We'll create CounterRenderless.js which is the definition of the logic component. It contains the state and logic but has no template. Instead, it uses a render function to declare a scoped slot.
The scoped slot exposes three props for use in the parent. Firstly the state, count
, the method, increment
, and the computed property, double
.
CounterRenderless.js
export default {
data: () => ({
count: 0
}),
methods: {
increment() {
this.count++;
}
},
computed: {
double () {
return this.count * 2;
}
},
render() {
return this.$scopedSlots.default({
count: this.count,
double: this.double,
increment: this.toggleState,
})
}
}
The scoped slot is the key aspect of logic components in this pattern as you'll now see.
Presentation component
Next, we'll create a presentation component to consume the renderless component and provide a fleshed-out display.
All the display markup goes into the scoped slot. As you can see, the scoped properties provide the same render context that we'd have if this template were directly attached to the logic component.
CounterWithButton.vue
<template>
<counter-renderless slot-scope="{ count, double, increment }">
<div>Count is: {{ count }}</div>
<div>Double is: {{ double }}</div>
<button @click="increment">Increment</button>
</counter-renderless>
</template>
<script>
import CounterRenderless from "./CountRenderless";
export default {
components: {
CounterRenderless
}
}
</script>
The renderless component pattern is very flexible and easy to understand. However, it's not as versatile as the previous methods, and really only has one use case - making component libraries.
Extending templates
Where all the above APIs and design patterns are limited is that they don't provide a way to extend a component template. While Vue assists with reuse of state and logic, the same can't be said for the template markup.
A hack for doing this is to use an HTML preprocessor like Pug which has in-built options for extending templates.
The first step is to create a base template in a .pug file. This should include any markup you want to be common across all the components extending it.
It must also include a block input
which serves as an outlet for the extended template.
BaseTemplate.pug
div.wrapper
h3 {{ myCommonProp }} <!--common markup-->
block input <!--extended markup outlet -->
To extend from this template in a consuming component you'll need to install the Pug plugin for Vue loader. Now, you can include the template fragment and extend it by again using the block input
syntax:
MyComponent.vue
<template lang="pug">
extends BaseTemplate.pug
block input
h4 {{ myLocalProp }} <!--gets included in the base template-->
</template>
You may at first think this is the same concept as slots, but the difference is the base template is not part of a separate component. It gets merged into the consuming component at compile-time, not at run-time as it would with slots.
If you're interested in using this method, I've written a separate article providing more complete directions.
Resources
About Anthony Gore
If you enjoyed this article, show your support by buying me a coffee. You might also enjoy taking one of my online courses!
Click to load comments...