In this article, we will talk about a somewhat important topic in Vue 3, which is the composition Api.
With the release of vue 3 a new important features comes. Among these features is the composition Api. The composition Api is a new way of manipulating Vue components to enable re-usability.
If you remember from Vue 2, there is the options api. In the options api we have to define separate component parts, like the data() method, methods section, computed section, watchers, etc.
However using the composition api we will define all these parts in a single area, this area is the setup() function. The setup() function is the core of the composition api, so in this function we can declare the data, computed, watchers, methods, etc.
From this the composition Api provides some benefits:
- Enable us to put all the component related code like data, computed, watchers, etc in a single place which is the setup().
- Enhances re-usability so we can reuse specific code in the setup() if two or more components share the same code by extracting this code in composable functions.
So are we forced to use the composition api in Vue 3?
The answer is no, we can use the options api in the same way as Vue 2 if we can’t deal with the composition api. As i mentioned above that composition api is optional but it can makes code much cleaner. In fact you can use the composition api in specific components only and the options api in the other components.
Example: In this example i simulate how to use the composition api.
- This app contains two pages which displays a list of posts from an external api. For simulation purposes i will consider that the data is stored in a json file.
- There is a search form in the top of each page to filter the posts.
- The first page displays the latest posts while the second page displays posts by specific tag.
- I will display the data first using the options api then we will switch this to the composition api.
- We will the vue-cli to create the app.
If you didn’t have vue cli already installed, install it with this command:
npm install -g @vue/cli
Next create a new Vue app with vue cli:
vue create vue-composition-api
Then cd into the project:
cd vue-composition-api/
Install vue-router
npm install vue-router@4
Launch the project using:
npm run serve
You can navigate into http://localhost:8080 to see your app.
Now let’s add the required pages and components for our example:
Open the project in the src/ directory create these folders:
- pages
- composables
Then in the pages/ directory create these components:
- Posts.vue
- PostsByTag.vue
Then we need to create two routes for those pages.
Open src/main.js and import the router as shown:
import { createApp } from 'vue' import {createRouter, createWebHistory} from 'vue-router'; import App from './App.vue' import Posts from "./pages/Posts"; import PostsByTag from "./pages/PostsByTag"; const routes = [ { path: '/', component: Posts }, { path: '/posts-by-tag/:tag', component: PostsByTag } ] const router = createRouter({ history: createWebHistory(), routes }); const app = createApp(App); app.use(router); app.mount('#app');
Here i have imported createRoute() and createWebHistory(), then i declared the routes, as shown there two routes for the home page which render Posts.vue component and posts by tag which render PostsByTag.vue component.
Then i create a router instance specifying the history and the array of routes. And finally i called app.use(router).
To display the correct route we should make little changes in the main component App.vue.
App.vue
<template> <router-link to="/"><img src="./assets/logo.png" width="50" /> Home </router-link> <router-view></router-view> </template> <script> export default { name: 'App', } </script> <style> #app { font-family: Avenir, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-align: left; color: #2c3e50; margin-top: 10px; } </style>
As you see i added the <router-view/> which display the component according to the current route.
In this point let’s start preparing to display the posts. I will assume that the posts we come from a json file. I created sample json file you can download from this link.
After downloading the json file copy it into the assets/ directory.
To load the json data into the project i created a helper javascript file in src/ called fetchPosts.js
src/fetchPosts.js
import json from "./assets/posts.json"; export function fetchPosts (tag = null, author = null) { let posts = json.data; if(tag) { posts = posts.filter(item => item.tags.includes(tag)); } if(author) { posts = posts.filter(item => item.owner.id == author); } return posts; }
In a real world project the fetchPosts.js may read data from external api.
The next step is to prepare to display the posts, we will update pages/Posts.vue like so:
pages/Posts.vue
<template> <h2>All Posts</h2> <searchbar @filter="filter" /> <PostList :posts="posts" /> </template> <script> import Searchbar from "../components/Searchbar"; import PostList from "../components/PostList"; import {fetchPosts} from "../fetchPosts"; export default { name: "Posts", data() { return { posts: [] } }, components: { PostList, Searchbar }, mounted() { this.preparePosts(); }, methods: { preparePosts() { this.posts = fetchPosts(); }, filter(filterData) { this.preparePosts(); if(filterData.keyword) { this.posts = this.posts.filter(item => (new RegExp(filterData.keyword , 'i')).test(item.text)); } if(filterData.user) { this.posts = this.posts.filter(item => item.owner.id == filterData.user); } if(filterData.publishdate) { this.posts = this.posts.filter(item => (new Date(item.publishDate)).toDateString() >= (new Date(filterData.publishdate)).toDateString()); } } } } </script>
In this page we are rendering the posts list. In the template i included two partial components the <searchbar /> and <PostList />. The <searchbar /> display the filter form and takes a custom event “filter” which i defined in the methods and the <PostList /> displays the list and takes “posts” as a prop which also i defined in the data.
In the mounted() hook in load the post list from fetchPosts() helper file. The filter() event filter the posts using different criteria like the keyword, user id or publishdate.
components/Searchbar.vue
<template> <label>Filter by</label> <input type="text" name="q" id="q" placeholder="Keyword" v-model="q" /> <select name="user" id="user" v-model="user" v-if="this.$route.path.indexOf('posts-by-author') == -1"> <option value="">select owner</option> <option v-for="user in this.ownerUsers" :key="user.id" :value="user.id">{{user.firstName + ' ' + user.lastName}}</option> </select> <input type="date" name="publishdate" id="publishdate" v-model="publishdate" /> </template> <script> import {fetchPosts} from "../fetchPosts"; export default { name: "Searchbar", emits: ["filter"], data() { return { ownerUsers: [], q: "", user: "", publishdate: "" } }, methods: { emitEvent() { this.$emit("filter", {keyword: this.q, user: this.user, publishdate: this.publishdate}); } }, watch: { q() { this.emitEvent(); }, user() { this.emitEvent(); }, publishdate() { this.emitEvent(); } }, mounted() { const jsonDate = fetchPosts(); jsonDate.forEach((item) => { if(this.ownerUsers.find(i => i.id == item.owner.id) == undefined) { this.ownerUsers.push(item.owner); } }); } } </script> <style scoped> input, select { padding: 5px; margin-left: 5px; } </style>
In this code i display a simple search form. In the <select /> element i displayed the user list to filter with which i retrieved from the json data in the mounted hook above. I added a watcher for each field so if any field value updates then we emit the “filter” event using this.$emit(‘filter’, data).
components/PostList.vue
<template> <section> <Post v-for="post in this.posts" :key="post.id" :post="post"></Post> </section> </template> <script> import Post from "./Post"; export default { name: "PostList", components: {Post}, props: { posts: Array } } </script> <style scoped> section { margin-top: 20px; margin-left: 5px; } </style>
components/Post.vue
<template> <article> <img :src="post.image" width="300" height="250" /> <h4>{{post.text}}</h4> <span class="author">By {{post.owner.title + ' ' + post.owner.firstName + ' ' + post.owner.lastName}} </span> <ul class="tags"> <li v-for="(tag, key) in post.tags" :key="key"> <router-link :to="'/posts-by-tag/' + tag">{{tag}}</router-link> </li> </ul> </article> </template> <script> export default { name: "Post", props: { post: Object } } </script> <style scoped> .author { font-size: 13px; color: #555; } .tags { list-style-type: none; } .tags li { display: inline-block; margin-left: 7px; } .tags li a { background-color: #21f7d9; padding: 3px 4px; border-radius: 7px; color: #000; text-decoration: none; font-size: 13px; } </style>
Now i refresh the page you will see the posts and you can filter through the form.
To illustrate more the problem we are dealing with, let’s update the other page PostsByTag.vue to display posts by specific tag.
PostsByTag.vue
<template> <h2>Posts by tag "{{$route.params.tag}}"</h2> <searchbar @filter="filter" /> <PostList :posts="posts" /> </template> <script> import Searchbar from "../components/Searchbar"; import PostList from "../components/PostList"; import {fetchPosts} from "../fetchPosts"; export default { name: "PostsByTag", components: {PostList, Searchbar}, data() { return { posts: [] } }, mounted() { this.preparePosts(); }, methods: { preparePosts() { this.posts = fetchPosts(this.$route.params.tag); }, filter(filterData) { this.preparePosts(); if(filterData.keyword) { this.posts = this.posts.filter(item => (new RegExp(filterData.keyword , 'i')).test(item.text)); } if(filterData.user) { this.posts = this.posts.filter(item => item.owner.id == filterData.user); } if(filterData.publishdate) { this.posts = this.posts.filter(item => (new Date(item.publishDate)).toDateString() >= (new Date(filterData.publishdate)).toDateString()); } } } } </script>
As you see this page contains exactly the same logic as Post.vue except that the preparePosts() function return posts filtered by tag which i got from this.$route.params.tag.
Moving To Composition
So let’s refactor this using the composition api, i will start with Posts.vue.
Posts.vue
<template> <h2>All Posts</h2> <searchbar @filter="filter" /> <PostList :posts="posts" /> </template> <script> import Searchbar from "../components/Searchbar"; import PostList from "../components/PostList"; import {onMounted, ref} from "vue"; import {fetchPosts} from "../fetchPosts"; export default { name: "Posts", components: { PostList, Searchbar }, setup() { let posts = ref([]); const preparePosts = () => fetchPosts(); onMounted(() => posts.value = preparePosts()); function filter(filterData) { let temp = preparePosts(); posts.value = temp; if(filterData.keyword) { posts.value = temp.filter(item => (new RegExp(filterData.keyword , 'i')).test(item.text)); } if(filterData.user) { posts.value = temp.filter(item => item.owner.id == filterData.user); } if(filterData.publishdate) { posts.value = temp.filter(item => (new Date(item.publishDate)).toDateString() >= (new Date(filterData.publishdate)).toDateString()); } } return { posts: posts, filter }; } } </script>
I updated the page above and removed everything including the data(), mounted() and methods and replaced it with the setup().
To define data variables as you would in the options api, we can use reactive ref from vue 3. There are two functions for this purpose the ref(initial val) and reactive(initial val) functions, in this case i used ref() to set the posts variable and initialized it with empty array.
let posts = ref([]);
To read or set the value from a reactive variable inside setup(), for ref you have to use “.value” property as for the posts variable i use posts.value. In the template you have to read the value using posts directly without “.value”.
The reactive variable enables the two way reactivity when displayed and updated through the template using directives like v-model.
Next the hooks in composition api is renamed and accepting a function so in this example the usual mounted() hook is replaced by onMounted() which accepts a function in i load the posts. the created() hook is replaced by onCreated() and so on.
To define a custom method like in the options api, simply declare it in setup(), like the filter() function which works the same as before.
Finally the setup() must return everything you need in the template so i returned posts and filter.
Now before updating the second page PostsByTag.vue let’s makes this code more clean by extracting it into a composable function.
Composable Functions
- Composable function is a normal javascript function which return an object.
- By convention the function name should start with “use”.
- You can create many composable functions in the same setup() function.
I will create two composable functions
composables/usePosts.js
import {onMounted, ref} from "vue"; import {fetchPosts} from "../fetchPosts"; export default function usePosts(criteria = null) { let posts = ref([]); let preparePosts = () => {}; if(criteria) { if(criteria.tag) { preparePosts = () => fetchPosts(criteria.tag); } } else { preparePosts = () => fetchPosts(); } onMounted(() => posts.value = preparePosts()); return { posts, preparePosts }; }
composables/useFilterPosts.js
export default function useFilterPosts(posts, preparePosts) { function filter(filterData) { let temp = preparePosts(); posts.value = temp; if(filterData.keyword) { posts.value = temp.filter(item => (new RegExp(filterData.keyword , 'i')).test(item.text)); } if(filterData.user) { posts.value = temp.filter(item => item.owner.id == filterData.user); } if(filterData.publishdate) { posts.value = temp.filter(item => (new Date(item.publishDate)).toDateString() >= (new Date(filterData.publishdate)).toDateString()); } } return { filter, filteredPosts: posts }; }
Now include the composable functions in Posts.vue
pages/Posts.vue
<template> <h2>All Posts</h2> <searchbar @filter="filter" /> <PostList :posts="posts" /> </template> <script> import Searchbar from "../components/Searchbar"; import PostList from "../components/PostList"; import usePosts from "../composables/usePosts"; import useFilterPosts from "../composables/useFilterPosts"; export default { name: "Posts", components: { PostList, Searchbar }, setup() { let {posts, preparePosts} = usePosts(); const {filteredPosts, filter} = useFilterPosts(posts, preparePosts); return { posts: filteredPosts, filter }; } } </script>
Our page now becomes short and clean.
In the first composable function i set the posts ref and invoked the onMounted() hook. The function returns the posts and the preparePosts() function which we pass in the second composable function.
In the second composable function i declared the filter() function as before. The function returns an object with the filtered posts and filter() function.
Then in the page i imported and called both functions and returns the same data.
Let’s do the same approach for PostsByTag.
pages/PostsByTag.vue
<template> <h2>Posts by tag "{{$route.params.tag}}"</h2> <searchbar @filter="filter" /> <PostList :posts="posts" /> </template> <script> import {useRoute} from "vue-router"; import Searchbar from "../components/Searchbar"; import PostList from "../components/PostList"; import usePosts from "../composables/usePosts"; import useFilterPosts from "../composables/useFilterPosts"; export default { name: "PostsByTag", components: {PostList, Searchbar}, setup() { const route = useRoute(); let {posts, preparePosts} = usePosts({tag: route.params.tag}); const {filteredPosts, filter} = useFilterPosts(posts, preparePosts); return { posts: filteredPosts, filter }; } } </script>
Now refresh the page you will see the posts as before and you can filter using keyword or user and you can click on any tag to navigate to posts by tag.
As you see how the composition api remove the duplicate and repetitive code. Using this technique try to make another page for posts by user.
Cool