There was a bit of a controversy last year when the Vue team decided to show new ideas for composing components. Once the dust had settled, the Composition API was better for the battle. In this article, I'll show you how the Composition API works and why I think it's a better option for building your Vue applications going forward.
What's Wrong with the Options API?
First of all, nothing's wrong with the options API. If you're happy with the Options API, stay with it. But I hope you can see why the Composition API is a better way to do it. When I first started using Vue, I really liked how they used an anonymous object to set up a component. It was easy and felt natural. The longer I used it, the more it started to feel weird. Its reliance on the manipulating the this pointer was hiding some of the magic of how Vue works.
For example:
export default {
data: () => {
return { name: "Shawn",};
},
methods: {
save: function () {
alert ('Name: ${this.name}'); // MAGIC
},
},
mounted: async function () {
this.name = "Shawn's Name"; // MAGIC
}
};
Because much of the work is done when the functions you define (e.g., save/mounted) are called, you have to really understand how the this pointer works. For example, can you tell me without compiling if this would have worked?
mounted: async function () {
this.load()
.then(function() {
this.name = "Shawn's Done";
});
}
No, it wouldn't. The reason is that the mounted
function's this
member has the Vue properties added. When you create a new nested
function (e.g., in the call to then()
), it creates a new scope and therefore a new this
member. You'd have to remember to fix it by either using an Arrow
function (e.g., () =>
) or wrapping the this pointer. For example, this small fix works:
mounted: async function () {
this.load()
.then( () => { // The fix
this.name = `Shawn's Done`;
});
}
But that requires quite a lot of finesse to get right. What feels simple and right at first, turns into a bit of a headache.
In addition, the way that properties are made reactive is an additional piece of magic. When the members are returned from the data
function, Vue wraps them (often using Proxy
objects) to be able to know when the object changes. Of course, this can be confusing if you try to replace the object. For example:
export default {
data: () => {
return {
name: "Shawn",
items: []
};
},
mounted: async function () {
this.load()
.then(result => {
// breaks reactivity
this.items = result.data;
});
}
};
This is one of the most common problems with Vue. Although these can be gotten around pretty simply, it's an impediment to learning how Vue works. Nothing is as frustrating as a bug that doesn't work and doesn't throw an error. These issues often end up happening with the Options API.
Lastly, there's a big issue with composing your components. Being able to use shared functionality was difficult with the Options API. Loading objects or methods that you then used in the component was hindered by the nature of the Options
object. To combat that, Vue uses the notion of mixins
. For example, you could create a mixin to extend a component like so:
import axios from "axios";
export default {
data: function () {
return { items: [] };
},
methods: {
load: async function() {
let url = "https://restcountries.eu/rest/v2/all";
let result = await axios.get(url);
this.items.splice(0, this.items.length, ...result.data);
},
removeItem: function (item) {
let index = this.items.indexOf(item);
if (index > -1) {
this.items.splice(index, 1);
}
}
}
Note that the mixin looks a lot like the Vue Options API. It simply does a merge of the objects to allow you to add on to specific components. You use a mixin by specifying it in the component:
import dataService from "./dataService.mixin";
export default {
mixins: [dataService],
...
The biggest issue with mixins is that you can't protect or easily know about name collision. It becomes trial and error to find out that a mixin was changed to use the same property or method of the component (or other mixins). Again, the magic is hidden behind the veneer of Vue and it makes debugging more difficult.
In the light of these limitations, the Composition API was born.
What's the Composition API?
Now that I've discussed the limitations of the Options API, let me introduce you to the Composition API. If you're still using Vue 2, you can still use the Composition API. To get started, you need to import the composition API library. First add it to your npm
package:
> npm i @vue/composition-api --save
To enable the composition API, you simply have to register the composition API (usually in the main.js/ts
):
import Vue from 'vue'
import VueCompositionAPI from '@vue/composition-api'
Vue.use (VueCompositionAPI)
Now that you've enabled it, let's take a look at the first Composition API component. At first, the look of a component looks similar:
export default {
setup() {
return { };
},
};
It still starts with an anonymous object but the only member (so far) is a method called setup
. Inside the setup
, you'll return any data you need. This is very much like the function-type of data property in the Options API:
export default {
setup() {
const name = "Shawn";
return { name };
},
};
Instead of just assigning the value in the return, create it with a local variable inside the setup. Why would you? Because of JavaScript closures. Let's extend this and make a function that will be used in the markup:
export default {
setup() {
const name = "Shawn";
function save() {
alert(`Name: ${name}`);
};
return { name, save };
},
};
The function can access the name because they're in the same scope. This is the magic of the Composition API. No magic, just JavaScript. What you can do with this pattern can become much more complex, but the basis of it all is just the closures. That's it.
In this example, the name is never changed. For Vue to be able to handle binding, it needs to know about any changes to the name. In this example, if you change the code to change the name in save()
, it won't be reflected in the UI:
export default {
setup() {
const name = "Shawn";
function save() {
name = name + " saved"; // name changed
alert(`Name: ${name}`);
};
return { name, save };
},
};
Reactivity
When you return the object of the bindable
object, Vue can see changes to the specific properties (the object is observed for changes). But because you're really assigning a new name in the Save
method, there's no way for Vue to know it's been changed. It's still seeing the old name. To address this, Vue uses two methods of reactivity: ref and reactive wrappers.
To make the name reactive, you can just wrap it with a ref
object:
import { ref } from "@vue/composition-api";
export default {
setup() {
const name = ref ("Shawn");
function save() {
name.value = name.value + " saved";
alert(`Name: ${name.value}`);
};
return { name, save };
},
};
The name
object is now wrapped with a ref
object. This is a simple reactive that provides a property called value
that represents the actual value. You're now assigning and reporting the name
using the value
property. Because it's using a ref
wrapper, the changes will be notified to the user interface. This is an important concept, as you're surfacing some of the magic so that it's more obvious what's happening here.
This works well with primitive objects, but for objects with their own state (e.g., your classes or arrays), changes to the value aren't enough. What you really need is to use a proxy
object so that any changes that happen inside functions on the object cause notifications of those changes.
import { ref, reactive } from "@vue/composition-api";
export default {
setup() {
const name = ref("Shawn");
const items = reactive([]);
function save () {
// Change Array
items.splice(0,items.length);
name.value = name.value + " saved";
alert(`Name: ${name.value}`);
};
return { name, save, items };
},
};
In this example, because you're calling splice to change the item collection, Vue needs to know about this change. You do that by wrapping the complex
object (the array, in this case) with another wrapper called reactive()
.
The reactive wrapper from the Composition API is the same as Vue 2's Vue.observable wrapper.
The difference between ref and reactive is that reactive wraps the object with a proxy, and ref is a simple value wrapper.
One thing to note is that the object that's returned to bind to the UI is also reactive by the runtime:
return {
name,
save,
items
};
// Object is reactive, but the members aren't automatically ref objects
Therefore, you don't need to wrap the object returning as a reactive
object. Now that you have the basics of properties and reactivity, let's talk about how the Composition API allows you to compose your components differently.
Once you have ref and reactive
objects, you can watch for changes. There are two methods to do this: watch
and watchEffect
. Watch allows you to respond to a change in a single object:
setup() {
const name = ref("Shawn");
watch(() => name, (before, after) => {
console.log("name changes");
});
return { name };
},
The watch
function takes two parameters: a callback to return the data to watch
, and a callback to be called when the change happens. It supplies the before
and after
values in case you need to use them.
Alternatively, you can use watchEffect
, which watches for any changes in the reactive
objects referred to in the component. It takes just a single callback:
watchEffect(() => {
console.log("Ch..ch...ch...changes.");
});
Now you can use reactivity to not only make the markup react to changes, but also to have your own code react to those same changes.
Composing Components
When composing components, the Composition API simply encourages you to use the scope and closures to solve the problem. For example, you might create a simple factory to create the objects for your component:
import axios from "axios";
export default function () {
const items = [];
async function load() {
const url = "https://restcountries.eu/rest/v2/all";
let result = await axios.get(url);
items.splice(0, items.length, ...result.data);
}
function removeItem(item) {
let index = items.indexOf(item);
if (index > -1) {
items.splice(index, 1);
}
}
return { items, load, removeItem };
}
In this case, you create a function that generates the functionality you need. You can import it into your component and use it by calling the function:
import { ref, reactive, onMounted } from "@vue/composition-api";
import serviceFactory from "./dataService.factory";
export default {
setup() {
const name = ref("Shawn");
// Create them by calling the exported function
const { load, removeItem, items} = serviceFactory();
// Use Load from factory function
onMounted(async () => await load());
function save () {
alert(`Name: ${this.name}`);
};
return {
load, // from factory function
removeItem, // from factory function
name,
save,
items // from factory function
};
},
};
This pattern allows you to create factory functions to inject the functionality you need in your component. Remember, in this pattern, you're getting a new instance of these items on every call (which is often what you want). But if you wanted to share the data (like showing the same data on different forms), you could always use a simpler instance pattern:
import axios from "axios";
export let items = [];
export async function load() {
const url = "https://restcountries.eu/rest/v2/all";
let result = await axios.get(url);
items.splice(0, items.length, ...result.data);
};
export function removeItem(item) {
let index = items.indexOf(item);
if (index > -1) {
items.splice(index, 1);
}
};
The difference here is that it's exporting each of the items separately (it doesn't generate them like in the factory pattern). If you use the items in separate components, you'll be manipulating the one instance. Using it is almost the same:
import { ref, reactive, onMounted }
from "@vue/composition-api";
// Import them instead of generating them
import { load, items, removeItem } from "./dataService";
export default {
setup() {
const name = ref("Shawn");
function save () {
alert(`Name: ${this.name}`);
};
return {
load, // from import
removeItem, // from import
name,
save,
items // from import
};
},
};
If you're already using Vuex, you can still use it in the Composition API. Vuex is more complex, but it does add some strictness to the read/write processes (only allowing changes in mutations). If you haven't worked with Vuex, I cover it in the January/February 2020 issue of CODE Magazine (https://www.codemag.com/Article/2001051/Vuex-State-Management-Simplified-in-Vue.js). Assuming you have a Vuex store created, the wiring up is different. There are no more helpers, as the magic that they do can be handled pretty simply:
import { ref, reactive, computed, onMounted } from "@vue/composition-api";
import store from "./store";
export default {
setup() {
const name = ref("Shawn");
const items = computed(() => store.state.items);
const removeItem = item => {
store.commit("removeItem", item);
};
onMounted(async () => {
await store.dispatch("load");
);
function save () {
alert(`Name: ${this.name}`);
};
return {
removeItem, // from function
name,
save,
items // from computed
};
},
};
Generally, state is turned into computed items: mutations and actions (e.g., commit
and dispatch
) are wrapped with simple functions. If necessary, they're returned in the object. Although the helpers were useful (and I'm sure someone will have a way to simplify this), this is, again, more obvious what's going on in these cases. You can compose your components more obviously using the Composition API. I've gotten to creating components, so let's talk about using components next.
Using Props
Because you're building components, you'd like to be able to use props to pass data to your components. How is this handled in the Composition API? Defining the props is the same as it is with the Options API:
export default {
name: "WaitCursor",
props: {
message: {
type: String,
required: false
},
isBusy: {
type: Boolean,
required: false
}
},
setup() {
}
}
But because the properties aren't added to the magic this pointer, how do you use the properties in setup? You can get access to the properties by adding the optional parameter to setup:
setup(props) {
watch(() => props.isBusy, (b,a) => {
console.log(`isBusy Changed`);
});
}
Additionally, you can add a second parameter to have access to the emit
, slots
, and attrs
objects, which the Options API exposes on the this pointer:
setup(props, context) {
watch(() => props.isBusy, (b,a) => context.emit("busy-changed", a));
}
Using Components
There are two ways to use components in Vue. You can globally register components (which is common for libraries) so that they can be used anywhere:
import WaitCursor from "./components/WaitCursor";
Vue.component("wait-cursor", WaitCursor);
More commonly, you'd add components to specific components that you're using. In Composition API, it's the same as it was with the Options API:
import WaitCursor from "./components/waitCursor";
import store from "./store";
import { computed } from "@vue/composition-api";
export default {
components: {
WaitCursor // Use Component
},
setup() {
const isBusy = computed(() =>store.state.isBusy);
return { isBusy };
},
};
Once you specify a component in the Composition API, your markup can just use it:
<div>
<WaitCursor message="Loading..." :isBusy="isBusy"></WaitCursor>
<div class="row">
<div class="col">
<App></App>
</div>
</div>
</div>
The way to use components isn't any different than it is in the Options API.
Using Composition API in Vue 3
If you're using Vue 3, you don't need to opt-into the Composition API. It's defaulted in the set up of a new project. The @vue/composition-api
library is only used for Vue 2 projects. The real change for Vue 3 is that when you need to import Composition APIs, you need to get them directly from Vue:
import { ref, reactive, onMounted, watch, watchEffect }
//from "@vue/composition-api";
from "vue";
Everything else is just the same. Just import from “vue”. In Vue 3, it's just a little simpler to use the Composition API as it's the default behavior.
Using Composition API in TypeScript and Vue 3
One of the main goals of Vue 3 was to improve the TypeScript experience. Using the Composition API certainly benefits from using TypeScript. There are type libraries for all of what I've talked about. But to add type safety, you do have to make some small changes to use TypeScript. First, when you create a component, you have to wrap your component's object with defineComponent
:
import { defineComponent, reactive, onMounted, ref } from "vue";
import Customer from "@/models/Customer";
export default defineComponent({
name: "App",
setup() {
...
}
});
Additionally, you can use types in your set up:
const customers = reactive([] as Array<Customer>);
const customer = ref(new Customer());
const isBusy = ref(false);
const message = ref("");
In these examples, the variables are inferred as types (for example, the Customers
object is Reactive<Customer[]>
). In addition, the API is typed so it will lower your chances to pass in the wrong data. Of course, additionally, IntelliSense is a big benefit if you're using TypeScript (especially in Visual Studio or VS Code), as seen in Figure 1.
Where Are We?
Whether you're a veteran Vue developer or brand new, getting used to the Composition API is going to require you to change your mindset. The move from convention to something that's more explicit may be uncomfortable, but I've found that I like the new model a lot better. I spend less time scratching my head about what's happening with function scope and trying to remember when I can and can't use the arrow functions. I hope I've convinced you.