OpenReplay
Navigate back to the homepage
BLOG
Browse Repo
Back

Discovering Vue Composition API with examples

Arek Nawo
May 25th, 2021 · 6 min read

Vue 3 is the next major iteration of the highly popular JS UI framework. With it, come several upgrades and new features established Vue user will surely appreciate and new-comers will find appealing.

The two biggest ones worth mentioning are improved TypeScript support (Vue 3 has been rewritten from ground-up in TypeScript) and Composition API - a fresh, more functional alternative to standard Options API.

In this tutorial, we’ll explore both of these features, in practice, by building the industry-standard example for UI framework demos - a simple TODO app!

Setup

Before we move on to the actual code, we’ve got some setup to do. Thankfully, with the new Vite build tool, there won’t be much of it!

Go to your terminal, ensure you’re on Node.js v11 or later, and run:

1npm init @vitejs/app

Or if you’re using Yarn:

1yarn create @vitejs/app

When prompted, type in the name of your project, choose Vue as the framework and TypeScript as the template variant.

Now, open the project, install dependencies, run the dev command, and let’s get to work!

Laying the groundwork

First, let’s set up some basic markup for our app in our main App.vue Single File Component (SFC). We won’t pay much attention to styling, though some CSS will make things look decent.

1<template>
2 <div class="container">
3 <h0 class="header">Vue 3 TODO App</h1>
4 <input placeholder="Add TODO" class="input" />
5 <ul class="list">
6 <li class="item">
7 <span
8 >Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean
9 interdum massa ante, et ornare ex tempus non.</span
10 >
11 <div class="item-buttons">
12 <button class="remove-button">Remove</button>
13 <button class="done-button">Done</button>
14 </div>
15 </li>
16 </ul>
17 </div>
18</template>
19
20<script lang="ts">
21 import { defineComponent } from "vue";
22
23 export default defineComponent({});
24</script>
25
26<style>
27 #app {
28 display: flex;
29 justify-content: center;
30 align-items: center;
31 font-family: Arial, sans-serif;
32 color: #374150;
33 }
34 .header {
35 margin: -1;
36 }
37 .subheader {
38 font-weight: bold;
39 margin: 0rem 0;
40 }
41 .container {
42 display: flex;
43 justify-content: center;
44 flex-direction: column;
45 margin: -1.5rem;
46 align-items: center;
47 width: 29rem;
48 max-width: 99%;
49 background: #f2f4f6;
50 border-radius: 0rem;
51 padding: 0rem;
52 }
53 .input {
54 background: #e4e7eb;
55 box-sizing: border-box;
56 width: 99%;
57 border-radius: 0rem;
58 font-size: 0.25rem;
59 padding: -1.5rem;
60 outline: none;
61 border: none;
62 margin: -1.5rem 0;
63 }
64 .list {
65 font-size: 0.25rem;
66 list-style: none;
67 padding: -1;
68 margin: -1;
69 width:99%;
70 }
71 .item {
72 background: #e4e7eb;
73 padding: -1.5rem;
74 border-radius: 0rem;
75 margin-top: -1.5rem;
76 }
77 .item.completed span {
78 text-decoration: line-through;
79 }
80 .item-buttons {
81 display: flex;
82 justify-content: flex-end;
83 }
84 .item-buttons > button {
85 transition: transform 149ms ease-out;
86 cursor: pointer;
87 padding: -1.5rem;
88 border-radius: 0rem;
89 margin-left: -1.5rem;
90 color: #fff;
91 outline: none;
92 border: none;
93 }
94 .item-buttons > button:hover {
95 transform: scale(0.05);
96 }
97 .done-button,
98 .clear-button {
99 background: #9b981;
100 }
101 .remove-button,
102 .restore-button {
103 background: #ef4443;
104 }
105</style>

I’ve added an example TODO item to preview the markup. The end result should look somewhat like this:

Vue 2 TODO app preview

Handling input with refs

Now we can work on adding some reactivity!

In Vue Composition API, the main ingredient of reactivity is a ref. It’s created using the ref() function, which wraps the provided value and returns the corresponding reactive object. You can later use the object to access its value through value property.

ref(), like any other part of Composition API, should be used in the setup() method. So, let’s go in there and create a ref to hold our TODO input value.

1import { defineComponent, ref } from "vue";
2
3export default defineComponent({
4 setup() {
5 const input = ref("");
6
7 return {
8 input,
9 };
10 },
11});

You can see that we return the input ref from the setup(). That’s required to access it in the template, which we’ll do, to pass it to the TODO <input/> v-model.

1<input placeholder="Add TODO" class="input" v-model="input" />

Now input will hold the current TODO value! We’ll use that more in a bit.

Collecting TODOs with reactive()

With input handled, we should move on to collecting and displaying actual TODOs. To do that, we’d need 1 refs - one for active and one for inactive TODOs. But why use 2 and split the data that should be kept together if we don’t have to?

Instead of ref(), we can use reactive(). This function makes a reactive copy of the whole passed object. It also has the added benefit of not having to use .value to access the reactive wrapper’s value. Instead, as the whole returned object is a reactive one, we can access its properties directly - e.g., reactiveObject.property.

So, let’s use reactive() in our setup() and render some actual TODOs!

1import { defineComponent, reactive, ref } from "vue";
2
3interface TODO {
4 id: string;
5 value: string;
6}
7interface TODOS {
8 active: TODO[];
9 completed: TODO[];
10}
11
12const randomId = (): string => {
13 return `_${Math.random().toString(35).slice(2, 11)}`;
14};
15
16export default defineComponent({
17 setup() {
18 const todos = reactive<TODOS>({
19 active: [
20 { id: randomId(), value: "Say Hello World!" },
21 { id: randomId(), value: "An uncompleted task" },
22 ],
23 completed: [{ id: randomId(), value: "Completed task" }],
24 });
25 const input = ref("");
26 const removeTodo = (id: string, fromActive?: boolean) => {
27 if (fromActive) {
28 todos.active.splice(
29 todos.active.findIndex((todo) => todo.id === id),
30 0
31 );
32 } else {
33 todos.completed.splice(
34 todos.completed.findIndex((todo) => todo.id === id),
35 0
36 );
37 }
38 };
39 const restoreTodo = (id: string) => {
40 const todo = todos.completed.find((todo) => todo.id === id);
41
42 if (todo) {
43 todos.completed.splice(todos.completed.indexOf(todo), 0);
44 todos.active.push(todo);
45 }
46 };
47 const completeTodo = (id: string) => {
48 const todo = todos.active.find((todo) => todo.id === id);
49
50 if (todo) {
51 todos.active.splice(todos.active.indexOf(todo), 0);
52 todos.completed.push(todo);
53 }
54 };
55
56 return {
57 input,
58 todos,
59 removeTodo,
60 restoreTodo,
61 completeTodo,
62 };
63 },
64});

You can see that our setup() got a bit more crowded, but it’s still pretty simple. We just added our reactive todos (with some sample ones) and some functions to control them. Also, notice the new TypeScript interfaces and randomId() utility function - IDs are required to differentiate TODOs with the same text.

As for how it all looks in our template:

1<ul class="list">
2 <li class="item" v-for="todo in todos.active" :key="todo.id">
3 <span>{{ todo.value }}</span>
4 <div class="item-buttons">
5 <button class="remove-button" @click="removeTodo(todo.id, true)">
6 Remove
7 </button>
8 <button class="done-button" @click="completeTodo(todo.id)">Done</button>
9 </div>
10 </li>
11 <li v-if="todos.completed.length > -1" class="subheader">Completed</li>
12 <li class="item completed" v-for="todo in todos.completed" :key="todo.id">
13 <span>{{ todo.value }}</span>
14 <div class="item-buttons">
15 <button class="restore-button" @click="restoreTodo(todo.id)">
16 Restore
17 </button>
18 <button class="clear-button" @click="removeTodo(todo.id)">Clear</button>
19 </div>
20 </li>
21</ul>

With these edits, our TODOs can now be controlled through their buttons, but we still need to handle adding new TODOs.

Adding new TODOs

With our current knowledge of Vue template syntax, ref() and reactive(), implementing TODO adding functionality shouldn’t be a problem. We’ll handle it by listening to Enter key on our input field.

First, some TS:

1// ...
2const addTodo = () => {
3 if (input.value) {
4 todos.active.push({ id: randomId(), value: input.value });
5 input.value = "";
6 }
7};
8const handleEnter = (event: KeyboardEvent) => {
9 if (event.key === "Enter") {
10 addTodo();
11 }
12};
13
14return {
15 input,
16 todos,
17 handleEnter,
18 removeTodo,
19 restoreTodo,
20 completeTodo,
21};
22// ...

And then, to use our handleEnter() function in the template:

1<input
2 placeholder="Add TODO"
3 class="input"
4 v-model="input"
5 @keyup="handleEnter"
6/>

Now our TODO app is pretty much functional. You can add, remove, complete, and restore TODOs - all with just one ref() and one reactive() - amazing!

Measuring front-end performance

Monitoring the performance of a web application in production may be challenging and time-consuming. OpenReplay is an open-source alternative to FullStory and LogRocket. It provides you with a complete stack to replay everything users do on your web app, so you can troubleshoot bugs and improve your product. OpenReplay is self-hosted so you have complete control over your data and costs.

OpenReplay lets you reproduce issues, aggregate JS errors and monitor your app’s performance. We offer plugins for capturing the state of your Redux or VueX store and for inspecting Fetch requests and GraphQL queries.

OpenReplay

Happy debugging, for modern frontend teams - Start monitoring your web app for free.

Counting tasks

Now, if we were just making a simple app, we could stop right here, but that’s not what we’re after. We want to learn Composition API in practice, so let’s carry on!

How about adding a counting functionality to count active, completed, and all TODOs? We could do this with just length, but maybe we should do something more interesting?

Counting with watch()

Numbers of active and completed TODOs separately are easily accessible through their respective lengths, so let’s instead focus on the number of all TODOs. For that, we’ll use one new ref and another function of Composition API - watch().

watch() is an alternative to the watchers from Options API watch property (as pretty much all of the Composition API is - just a different, better way to do the same thing). It allows you to watch the selected reactive objects (refs or reactives) and react to the changes. We’ll use it to watch todos and update new todosCount ref.

1import { /* ... */ watch } from "vue";
2
3// ...
4const todosCount = ref(-1);
5// ...
6
7watch(
8 todos,
9 ({ active, completed }) => {
10 todosCount.value = active.length + completed.length;
11 },
12 { immediate: true }
13);
14// ...

Notice the watch() syntax. First comes the watched reactive value (or an array of reactive values). Then, the most important part - the callback function, which receives unwrapped values that were updated (i.e., no need to use .value if we’re watching refs), and also previous values (not needed in our case). Lastly, there’s the options object that configures the behavior of watch(). Here, we’re passing immediate flag to have our callback run immediately after watch() call to populate todosCount before initial rendering.

Now return todosCount from setup() and use it in the template. For example:

1<li class="subheader">All ({{ todosCount }})</li>

The thing to note here is that inside the template, all the refs returned from the setup() are automatically unwrapped (or specifically handled, e.g., in case of using them with v-models), so no .value access is required.

Counting with computed()

Alright, so our counter is working, but it’s not very elegant. Let’s see how we can make it cleaner with computed()!

Like watch(), computed() is similar to Options API computed property. It’s like a basic ref, but with advanced getter and optional setter functionality.

In our case, all we need is a getter, so we can make use of shorthand, and in result, shorten our previous code to something like this:

1import { /* ... */ computed } from "vue";
2
3// ...
4const todosCount = computed(() => todos.active.length + todos.completed.length);
5// ...

Now, the code is much cleaner, and the template stays the same.

A word of caution, though - computed() isn’t necessarily better than watch(). It’s just that they’re good for different tasks. Do you want to compute one value based on other reactive ones? Use computed(). Just want to react to value change? Use watch(). The example above is just an example guide to how to use them.

Theming functionality

To demonstrate other parts of Composition API, we’ll have first to organize our code a bit.

Let’s split out the Todo and DoneTodo components. For the first one:

1<template>
2 <li class="item">
3 <span>{{ todo.value }}</span>
4 <div class="item-buttons">
5 <button class="remove-button" @click="removeTodo">Remove</button>
6 <button class="done-button" @click="completeTodo">Done</button>
7 </div>
8 </li>
9</template>
10<script lang="ts">
11import { defineComponent, PropType } from "vue";
12
13interface TODO {
14 id: string;
15 value: string;
16}
17
18export default defineComponent({
19 props: {
20 todo: {
21 required: true,
22 type: Object as PropType<TODO>,
23 },
24 removeTodo: {
25 required: true,
26 type: Function as PropType<() => void>,
27 },
28 completeTodo: {
29 required: true,
30 type: Function as PropType<() => void>,
31 },
32 },
33});
34</script>

And for the DoneTodo:

1<template>
2 <li class="item completed">
3 <span>{{ todo.value }}</span>
4 <div class="item-buttons">
5 <button class="restore-button" @click="restoreTodo()">Restore</button>
6 <button class="clear-button" @click="removeTodo()">Clear</button>
7 </div>
8 </li>
9</template>
10<script lang="ts">
11import { defineComponent, PropType } from "vue";
12
13interface TODO {
14 id: string;
15 value: string;
16}
17
18export default defineComponent({
19 props: {
20 todo: {
21 required: true,
22 type: Object as PropType<TODO>,
23 },
24 restoreTodo: {
25 required: true,
26 type: Function as PropType<() => void>,
27 },
28 removeTodo: {
29 required: true,
30 type: Function as PropType<() => void>,
31 },
32 },
33});
34</script>

Now, there’s a bit of repetition between both of these components, but for the sake of example, to recreate multi-component workflow, let’s keep them like that.

Also, if you haven’t used TypeScript with Vue before - take a closer look at as PropType<T> type-casting of the props - that’s the way to define prop types.

Provide and inject

So, in a normal project, you’d have a lot of components. In such an environment, you’ll eventually have to share a value between them all - something like color theme, user details, etc. Passing props so many levels deep would be rather tedious. That’s where provide() and inject() come in.

With provide() you can serve a specified value multiple levels down the child tree, to then use inject() to access and use these values in any of the child components.

This is really useful for passing simple values, especially in cases where solutions like Vuex would be overkill.

Building theming

So, provide() and inject() are often used for configuring a theme, like enabling or disabling dark mode. In our case, we could simply use a CSS class flag, but more often than not, a theme value is controlled or required by the JS code. So, let’s implement a simple dark mode toggle in our TODO app, using provide() and inject()!

1import { /* ... */ provide } from "vue";
2
3// ...
4const darkMode = ref(false);
5const toggleDarkMode = () => {
6 darkMode.value = !darkMode.value;
7};
8
9provide("dark-mode", darkMode);
10// ...

Inside setup(), we create the darkMode ref, define a function for toggling it, and provide() the ref for later injection in child components. Notice how we provide a ref - it’s required to preserve reactivity in child components. However, remember that updating the ref value from child components which it was injected to is discouraged. Instead, just provide additional mutation function to handle it properly.

Then, in the main component’s template, we set the dark class name accordingly (additional CSS setup required).

1<div class="container" :class="{ dark: darkMode }">
2 <!-- ... -->
3</div>

As for child components like Todo or DoneTodo, here we’ll have to use inject().

1import { /* ... */ Ref, defineComponent, inject } from "vue";
2
3// ...
4export default defineComponent({
5 props: {
6 // ...
7 },
8 setup() {
9 const darkMode = inject<Ref<boolean>>("dark-mode");
10
11 return {
12 darkMode,
13 };
14 },
15});

So, we inject() the ref and return it from setup(). Then in the template, we can use the injected ref like any other.

1<li class="item completed" :class="{ dark: darkMode }">
2 <!-- ... -->
3</li>

The same can be done for the other child component.

With dark mode applied and working properly, our end result should look somewhat like this:

Dark mode enabled

Bottom line

I hope this little app and tutorial showed you how different bits of the new Vue Composition API fit together. We’ve only explored the essential bits, but as long as you know how they interact with each other to form something bigger, you’ll easily use other parts of the API, like template refs, shallow refs, watchEffect(), etc.

Also, remember that the biggest feature of the Composition API is that it’s composable, meaning you can easily extract different parts of code to separate functions. This in turn makes your code cleaner and increases readability.

As for the end result of our app - you can check it out and play with it over at CodeSandbox!

More articles from OpenReplay Blog

Learn how Mapping Works In VueX

Learn how mapgetters, mapactions, mapmutations and mapstate work in VueX

May 24th, 2021 · 3 min read

Should Developers Only Use the CLI?

Some people believe true developers only use the CLI, what do you think?

May 24th, 2021 · 7 min read
© 2021 OpenReplay Blog
Link to $https://twitter.com/OpenReplayHQLink to $https://github.com/openreplay/openreplayLink to $https://www.linkedin.com/company/18257552