Higher Order Components in Vue.js
As described in React’s documentation, Higher Order Component is a function which takes as an argument a component and returns newly created component. Returned component is usually augmented with features provided by HOC (Higher Order Component). Higher Order Component is not a piece of software which can be grabbed and installed, it’s a technique which may be helpful in writing reusable and maintainable code.
This post describes how to create a Higher Order Component in Vue.js base on the example that is provided in React’s documentation.
Step 1. Components Setup
The artificial application I created for the purpose of this article consists of two components: CommentsList
and BlogPost
. Both components are rendered inside App
component, the main component of the application.
# App.vue<template>
<div id="app">
<blog-post/>
<comments-list/>
</div>
</template>
<script>
import CommentsList from './components/CommentsList'
import BlogPost from './components/BlogPost'export default {
name: 'app',
components: {
'blog-post': BlogPost,
'comments-list': CommentsList
}
}
</script>
CommentsList
component displays list of comments fetched from the external data source. Additionally, on mounted
hook an event listener is added which listens to changes in the data source and updates comment’s list accordingly. On hook beforeDestroy
the listener is removed.
# components/CommentsList.vue<template>
<ul>
<li
v-for="(comment, index) in comments"
:key="index"
>{{comment}}</li>
</ul>
</template>
<script>
import DataSource from '../store/source.js'
export default {
name: 'comments-list',
data() {
return {
comments: DataSource.getComments()
}
},
methods: {
handleChange() {
this.comments = DataSource.getComments()
}
},
mounted() {
DataSource.addChangeListener(this.handleChange)
},
beforeDestroy() {
DataSource.removeChangeListener(this.handleChange)
}
}
</script>
BlogPost
component displays a blog post content. Similarly as CommentsList
, it fetches its data from the external data source and updates the post’s content on every change in external data source.
# components/BlogPost.vue<template>
<div>
{{blogPost}}
</div>
</template>
<script>
import DataSource from '../store/source.js'
export default {
data() {
return {
blogPost: DataSource.getBlogPost()
}
},
methods: {
handleChange() {
this.blogPost = DataSource.getBlogPost()
}
},
mounted() {
DataSource.addChangeListener(this.handleChange)
},
beforeDestroy() {
DataSource.removeChangeListener(this.handleChange)
}
}
</script>
BlogPost
and CommentsList
components share four functionalities:
- Fetch the data from the external data source (in this case from
DataSource
) insidemounted
hook - Update the
data
on every update in the external data source - Add the change listener to the data source
- Remove the change listener from the data source
In order to avoid code repetitions the shared logic between BlogPost
and CommentsList
can be extracted to Higher Order Component.
Step 2. Higher Order Component
In this step I’ll move the duplicated code to Higher Order Component called withSubscription
.
Higher Order Component is a function that takes a component as an argument and returns a new component. Let’s write it in Vue
# hocs/withSubscription.jsimport Vue from 'vue'
import CommentsList from '~/components/CommentsList.vue'const withSubscription = (component) => {
return Vue.component('withSubscription', {
render(createElement) {
return createElement(component)
}
}
}const CommentsListWithSubscription = withSubscription(CommentsList)
At this point Higher Order Component doesn’t do much. It simply takes a component and creates a new component that renders the passed component.
Next step is to implement shared logic in it. I need to add mounted
, beforeDestroy
hooks and handleChange
method which will be called upon every update.
# hocs/withSubscription.jsimport DataSource from '../store/source'
import Vue from 'vue'
const withSubscription = (component) => {
return Vue.component('withSubscription', {
render(createElement) {
return createElement(component)
},
methods: {
handleChange() {
}
},
mounted() {
DataSource.addChangeListener(this.handleChange)
},
beforeDestroy() {
DataSource.removeChangeListener(this.handleChange)
}
})
}
export default withSubscription
Now the new component returned by the Higher Order Component has required lifecycle hooks. The handleChange
method is left empty. Both components havehandleChange
method, however, this method has slightly different implementation in each component.
Higher Order Component can accept more than one argument. Currently, withSubscription
accepts only component as an argument. In order to call custom logic inside handleChange
second argument is needed. The second argument is the method which should be called on every data source change. Passed method returns updated data which has to be passed down to the newly created component as a prop.
# hocs/withSubscription.jsimport DataSource from '../store/source'
import Vue from 'vue'
const withSubscription = (component, selectData) => {return Vue.component('withSubscription', {
render(createElement, context) {
return createElement(component, {
props: {
content: this.fetchedData
}
})
},
data() {
return {
fetchedData: null
}
},
methods: {
handleChange() {
this.fetchedData = selectData(DataSource)
}
},
mounted() {
DataSource.addChangeListener(this.handleChange)
},
beforeDestroy() {
DataSource.removeChangeListener(this.handleChange)
}
})
}
export default withSubscription
The usage of Higher Order Component inside App.vue
looks as follows
# App.vue<template>
<div id="app">
<blog-post/>
<comments-list/>
</div>
</template>
<script>
import CommentsList from './components/CommentsList'
import BlogPost from './components/BlogPost'
import withSubscription from './hocs/withSubscription'const BlogPostWithSubscription = withSubscription(BlogPost, (DataSource) => {
return DataSource.getBlogPost()
})
const CommentsListWithSubscription = withSubscription(CommentsList, (DataSource) => DataSource.getComments())
export default {
name: 'app',
components: {
'blog-post': BlogPostWithSubscription,
'comments-list': CommentsListWithSubscription
}
}
</script>
And here is the code of BlogPost
and CommentsList
# components/BlogPost.vue<template>
<div>
{{content}}
</div>
</template>
<script>
export default {
props: ['content']
}
</script>----# components/CommentsList.vue<template>
<ul>
<li v-for="(comment, index) in content" :key="index">{{comment}}</li>
</ul>
</template>
<script>
export default {
name: 'comments-list',
props: ['content']
}
</script>
It all looks very nice but there is one missing piece. What if I need to pass a blog post ID to BlogPost
? Or what if I need to emit an event from BlogPost
to App
component? With current implementation it won’t work.
Step 3. Handling Props and Events in Higher Order Component
Firstly, let’s change a bit the implementation of the getBlogPost
method in DataSource
. It needs to take the post’s id as an argument in order to know which post to fetch and return. Since the actual getBlogPost
call happens inside BlogPost
component it makes sense to pass as a prop the desired blog post id and make use of it when the getBlogPost
method is called. In order to do so I need to do two things: pass theid
prop from the App
component down to the BlogPost
component and change the function I pass to the Higher Order Component so it accepts the second argument — the props it has to pass down further to the BlogPost
.
# App.vue<template>
<div id="app">
<blog-post :id="1"/>
</div>
</template>
<script>import BlogPost from './components/BlogPost'
import withSubscription from './hocs/withSubscription'const BlogPostWithSubscription = withSubscription(BlogPost, (DataSource, props) => {
return DataSource.getBlogPost(props.id)
})export default {
name: 'app',
components: {
'blog-post': BlogPostWithSubscription
}
}
</script>---# components/BlogPost.vue<template>
<div>
{{content}}
</div>
</template>
<script>
export default {
props: ['content', 'id']
}
</script>
Now, I need to update the Higher Order Component so it knows how to pass down the props to the component it renders.
# hocs/withSubscription.jsimport DataSource from '../store/source'
import Vue from 'vue'
const withSubscription = (component, selectData) => {
const originalProps = component.props || [];
return Vue.component('withSubscription', {
render(createElement) {
return createElement(component, {
props: {
...originalProps,
content: this.fetchedData
}
})
},
props: [...originalProps],
data() {
return {
fetchedData: null
}
},
methods: {
handleChange() {
this.fetchedData = selectData(DataSource, this.$props)
}
},
mounted() {
DataSource.addChangeListener(this.handleChange)
},
beforeDestroy() {
DataSource.removeChangeListener(this.handleChange)
}
})
}
export default withSubscription
First thing which is added to HOC is reading the original props from the component it renders. Those properties are saved inside originalProps
constant. In Vue a component has to to define which props it accepts. The withSubscription
has to accept the same props as the component it renders in order to be able to pass them down to it later. That’s done with this line of code:
return Vue.component('withSubscription', {
...
props: [...originalProps], # <= this line
...
}
The last piece which was updated is the selectData
function call inside thehandleChange
method. The second argument was added — the props of the Higher Order Component — this.$props
. The $props
property is a Vue Component instance property available since Vue version 2.2.
I covered passing down the props to a child component, the last piece missing is events emission from a child component up to its parent.
Let’s add event listener inside App.vue
component and something that emits an event inside BlogPost.vue
.
# App.vue
<template>
<div id="app">
<blog-post :id="1" @click="onClick"/>
</div>
</template>---# components/BlogPost.vue<template>
<div>
<button @click="$emit('click', 'aloha')">CLICK ME!</button>
{{data}}
</div>
</template><script>
export default {
props: ['data', 'id']
}
</script>
It’s important to remember that I don’t render BlogPost
directly inside App
, there is a middleman — the withSubscription
HOC.
In order to pass down event listeners to rendered component I need to add one line of code inside the Higher Order Component.
# hocs/withSubscription.jsreturn Vue.component('withSubscription', {
...
on: {...this.$listeners} # <= this line,
})
Similarly as with this.$props
, there is an instance property $listener
which contains the parent-scope v-on
event listeners.
Thank you for reading this article. Comments are more than welcome. The main goal of writing this article is to share the knowledge but with each article I have also a second goal — to learn :)
The complete application from the article can be found on github: https://github.com/bognix/vue-hoc
After great feedback I decided to create another article where I compare this approach with scoped slots and mixins — Do we need Higher Order Components in Vue.js? Happy reading!