🇬🇧 Vue 3 and composition API
Hello there!
I loved Vue JS at the first sight. It for me the cleanest way to build a complex component based application. I tried React but I wasn't fond of the jsx approch, I tried native Custom Element but the boilerplaiting part is really to heavy to be acceptable. So I tried framework like Svelte or Lit Element all are interesting, but I always come back to Vue 💕.
Why ? Because of is SFC ( Single File Component) where you can mix the template, the logic and the style.
So when I heard that Vue will be redesign, I was both excited and worried. Worried to loose some of the features I love, but excited to see what will come next.
Installation
You can install Vue 3 using npm, the beta package is available. To create a project we will use Snowpack but you are free to use webpack or another tool if you want. To create a project with snowpack see my article on the subject. Personaly I'll create the project with:
npx create-snowpack-app composition --template @snowpack/app-template-vue --use-yarn
I assume your project is up and running.
Please verify that you're using the 3rd version of Vue 😉.
Composition API
The flagship feature of Vue 3 is its composition API. Let's check out does it mean.
First wipe out the App.vue and replace its content by:
<template>
Component
</template>
<script>
export default {
name: 'App'
}
</script>
After hot-reload you should have Component written of your screen.
Setup method
Most of Vue3 changes comes with the new setup
property, its a method like is mounted
and created
that performs action just after props
are defined.
A quick test can explain us the lifecycle of this new setup
hook.
<script>
export default {
name: 'App',
setup() {
console.log("setup")
},
beforeCreate() {
console.log("beforeCreate")
}
}
</script>
Will display
setup
beforeCreate
Let's start to play with the setup
hook.
<template>
{{name}}
</template>
<script>
export default {
name: 'App',
setup() {
return {
name: "test"
}
}
}
</script>
This will display
test
Ok it's a quite it feels like the data
<template>
{{name}}
</template>
<script>
export default {
name: 'App',
data() {
return {
name: "test"
}
}
}
</script>
But bear with me, there is a logic to do things like this.
Methods
The setup
allows to do a lot of things including defining methods.
<template>
{{count}}
<button @click="inc">Inc</button>
</template>
<script>
export default {
name: 'App',
setup() {
let count = 0;
function inc() {
console.log("inc:" + count)
count++
}
return {
count,
inc
}
}
}
</script>
The method inc
is well defined because pushing the button writes into the console the inc 0
, inc 1
and so on.
But the counter never increments on screen, it lacks something to tell to the view to redraw itself with the correct count
value.
Reactivity
It's time to introduce a new concept: the reactivity. Vue 3 composition API provides two ways to implement this.
Local state
The API gives us the reactive
method which wraps the object given into a Proxy that will add some behavior to it. Like tell to the view that something has changed.
Be carreful! The parameter passed to reactive
method has to be an object or extends from it.
<template>
{{state.count}}
<button @click="inc">Inc</button>
</template>
<script>
import {reactive} from "vue";
export default {
name: 'App',
setup() {
let state = reactive({
count: 0
});
function inc() {
console.log("inc: "+state.count)
state.count++
}
return {
state,
inc
}
}
}
</script>
Now the view is updating! 🎉
Weak reference pointer
Another way is to use ref
, it's quite the same thing but with two differences.
You can pass anything to ref
method not only an object.
But the drawback is that you can't access to the the inner value wrapped directly. You have to use the property value
to achieve this.
<template>
{{count}}
<button @click="inc">Inc</button>
</template>
<script>
import {ref} from "vue";
export default {
name: 'App',
setup() {
let count = ref(0)
function inc() {
console.log("inc: "+count.value)
count.value++
}
return {
count,
inc
}
}
}
</script>
Computed
This allows to return an immutable reference on a reactive data. It takes a closure without argument and returns value modified or not.
<template>
{{count}} + 1 = {{countPlusOne}}
<button @click="inc">Inc</button>
</template>
<script>
import {ref, computed} from "vue";
export default {
name: 'App',
setup() {
let count = ref(0);
let countPlusOne = computed(() => count.value + 1)
function inc() {
console.log("inc: "+count.value)
count.value++
}
return {
count,
inc,
countPlusOne
}
}
}
</script>
If you tried to do :
countPlusOne++
You'll break the data
To do so, you have to provide an object like this:
{
get: () => {}
set: val => {}
}
This allows to transform our value to a writable reference.
<template>
{{countRef}} + 1 = {{count}}
<button @click="inc">Inc</button>
</template>
<script>
import {ref, computed, unref} from "vue";
export default {
name: 'App',
setup() {
let countRef = ref(0);
let count = computed({
get: () => countRef.value,
set: val => countRef.value = unref(val)
})
function inc() {
count.value++
}
return {
count,
inc,
countRef
}
}
}
</script>
Yes this example is useless, but the next will clear your mind about this.
The unref
is just here to explicitely unref the value. In typescript it's mandatory, in classic javascript you can skip this step. But it's a good practice.
Lifecycle
The setup
hook has is own lifecycle system. Thanks to it, you can define behaviors.
<script>
import {onMounted, onBeforeMount} from "vue";
export default {
name: 'App',
setup() {
onMounted(() => {
console.log('setup mounted!')
})
onBeforeMount(() => {
console.log('setup before mount')
})
},
mounted() {
console.log('mounted!')
},
beforeMount() {
console.log('before mount')
}
}
</script>
Result to this in the console:
setup before mount
before mount
setup mounted!
mounted!
As we can see the setup hook is called right before the associated component hook.
It be can used for example to start something when the component is mounted.
Watching reactive properties
You can watch change on your properties. Vue 3 gives you two methods
WatchEffects
Listens all reactive properties and run the provided callback at each change.
<template>
{{count}} + 1 = {{countPlusOne}}
<button @click="inc">Inc</button>
</template>
<script>
import {ref, computed, watchEffect} from "vue";
export default {
name: 'App',
setup() {
let count = ref(0);
let countPlusOne = computed(() => count.value + 1)
function inc() {
count.value++
}
watchEffect(() => {
console.log("inc: "+count.value)
})
return {
count,
inc,
countPlusOne
}
}
}
</script>
The method provides much more use cases.
Watch
Same that watchEffects
but allow to lazily watch the property. If you have a bunch of properties with the watchEffects
all properties are listened. But sometimes you only need to watch one property. Example:
<template>
count1: {{count1}} | count2: {{count2}}
<button @click="inc1">Inc count 1</button>
<button @click="inc2">Inc count 2</button>
</template>
<script>
import {ref, watch, watchEffect} from "vue";
export default {
name: 'App',
setup() {
let count1 = ref(0);
let count2 = ref(2);
function inc1() {
count1.value++
}
function inc2() {
count2.value++
}
watchEffect(() => {
console.log("watchEffects count1: "+count1.value)
console.log("watchEffects count2: "+count2.value)
})
watch(count2, () => {
console.log("watch count2: " +count2.value)
})
return {
count1,
count2,
inc1,
inc2,
}
}
}
</script>
There is two buttons one increments the reactive property count1
the other increments count2
.
If you push the Inc count 2
button, you see this.
watchEffects count1: 0
watchEffects count2: 2
watchEffects count1: 0
watchEffects count2: 3
watch count2: 3 <-- Only count2
But if you push Inc count 1
button
watchEffects count1: 0
watchEffects count2: 2
watchEffects count1: 1
watchEffects count2: 2
The count2 watch
isn't triggered. Therefore you can precisely target the right property. Here don't trigger something on count1 changes.
Props and context
Props
You can access to props passed to the component from the first parameter of the setup
hook.
// index.js
import { createApp } from "vue";
import App from "./App.vue";
createApp(App, {
suffix: 'toto'
}).mount("#app");
<!-- App.vue -->
<template>
{{name}}
</template>
<script>
export default {
name: 'App',
props: {
suffix: {
type: String,
default: ''
}
},
setup(props) {
return {
name: "test_"+props.suffix
}
}
}
</script>
Will display test_toto
.
Be carreful! Don't try to destructure the props
parameter it will lose is reactivity. Don't do setup({suffix}) {...}
.
Context
The second parameter of the setup
hook provides a context.
Let's log it to see what it involves.
<!-- App.vue -->
<script>
export default {
name: 'App',
setup(props, context) {
console.log(context)
}
}
</script>
This give us an object:
{
emit : [...],
attrs: [...],
slots: [...]
}
No doubt it the same thing as:
For the science ! Let's try the emit
it's the simple one to test.
We need two components. One emitter and one receiver.
<!-- Emitter.vue -->
<template>
<button @click="handleClick">Button</button>
</template>
<script>
export default {
name: "Emitter",
setup(props, {emit}) {
function handleClick() {
emit('custom', "click on button")
}
return {handleClick}
}
};
</script>`
<!-- App.vue -->
<template>
<Emitter @custom="handleEmit"></Emitter>
</template>
<script>
import Emitter from "./Emitter";
export default {
name: 'App',
components: {Emitter},
setup() {
function handleEmit(ev) {
console.log(ev)
}
return {handleEmit}
}
};
</script>
If you click on the button the console writes click on button
, it works 🙂.
Split the code
Ok good but it's just a more complicate way to do what you we can already realise with Vue 2.
What is the idea behind all of this?
Simple example
First let's create a new file called logic.js
.
// logic.js
import {ref} from "vue";
export default function buildName() {
let name = ref("anonyme");
return {
name
}
}
Then use this method into the setup
hook
<!-- App.vue -->
<template>
{{name}}
</template>
<script>
import buildName from "./logic";
export default {
name: 'App',
setup() {
return buildName()
}
}
</script>
"anonyme" will be displayed. Congrats ! You just extract everything out of the compoment. This implies that you can reuse this buildName
method in any components you want.
Let's complexify a little bit:
// logic.js
import {ref, computed, unref} from "vue";
export default function buildName() {
let nameRef = ref("anonyme");
let name = computed({
get: () => nameRef.value,
set: val => nameRef.value = unref(val)
})
return {
name
}
}
<!-- App.vue -->
<template>
<input v-model="name">
<p>I'm {{name}}</p>
</template>
<script>
import buildName from "./logic";
export default {
name: 'App',
setup() {
return buildName()
}
}
</script>
Good we've created a simple input text system, if you type something the text under will change.
Compose behaviors
Why not adding a password input but a little bit more complex:
- a password must have more than 4 characters
- at least one letter
- at least on number
- two "f" consecutive character
If all rules are fulfilled, the border must be green.
Otherwise the border must be red.
First the password validation rules!
// checkPassword.js
const compose = (...fns) => x => fns.reduce((r, f) => f(r), x);
const matchRegexp = regex => ({result, value}) => {
if (result !== null && result !== false) {
result = regex.exec(value) !== null
}
return {result, value}
}
function hasAtLeastOneLetter(data) {
const regex = /[a-zA-Z]+/gm
return matchRegexp(regex)(data)
}
function hasAtLeastOneNumber(data) {
const regex = /\d+/gm
return matchRegexp(regex)(data)
}
const hasToken = token => data => {
const regex = new RegExp(`.*${token}.*`)
return matchRegexp(regex)(data)
}
const numberOfCharacter = count => ({result, value}) => {
if (result !== null && result !== false) {
result = value.length >= count
}
return {result, value}
}
export function checkPassword(password) {
let pipe = compose(
numberOfCharacter(5),
hasAtLeastOneLetter,
hasAtLeastOneNumber,
hasToken("ff")
);
let {result} = pipe({value: password})
return result;
}
Above a possible implementation using functional programming and composition ^^ ( I know it's not really optimized 😛 )
Then we add a new function in logic.js
// logic.js
export function buildName() {
[...]
}
const RED = '#ff0000';
const GREEN = "#4caf50";
export function buildPassword() {
let passwordRef = ref("");
let style = ref({
padding: "5px",
borderWidth: "3px",
borderStyle: "solid",
borderColor: RED
})
let password = computed({
get: () => passwordRef.value,
set: val => passwordRef.value = unref(val)
})
watch(passwordRef, () => {
style.value.borderColor = checkPassword(password.value) ? GREEN : RED;
})
return {
password,
style
}
}
This setup method will return both the reactive property handling the password value and the style of the password input border. This property is reactive following the password value.
That we can use it into our component.
<!-- App.vue -->
<template>
<input v-model="name">
<p>I'm {{name}}</p>
<input
type="password"
:style="passwordBehavior.style"
v-model="passwordBehavior.password">
</template>
<script>
import {buildName, buildPassword} from "./logic";
export default {
name: 'App',
setup() {
let {name} = buildName();
let passwordBehavior = buildPassword();
return {
name,
passwordBehavior
}
}
};
</script>
<style>
*:focus {
outline: none;
}
</style>
We just add a quite complex behavior but our component logic remains clean and simple. And the best is that you can use this buildPassword
and its password validation in any component you want.
That's the heart of the composition API, you compose behaviors to create more complex one.
This design pattern allow us to "inherit" of more than one behavior. If you know the mixins
all of this must seem very familiar.
The interesting part of composition API against mixins is that there is no merge conflict strategy to define. In mixins all properties and methods must have an unique name otherwise they are overwrite.
With composition you do what you want and you overwrite properties in the way you want.
Share the state
One question I asked myself while I was learning the composition API is how can I share the state between components?
Let's say we have a Chuck Norris joke provider. And some components that can transform the "Chuck Norris" term in something else. But the rest of sentence must remains the same in all of the components.
There is a lot of ways to achieve this.
Child component
The JokeProvider has as job to get a joke from an API and dispatch it to his children.
<!-- JokeProvider.vue -->
<template>
<button @click="getJoke">Update Joke</button>
<div class="jokes">
<Joke :text="joke"></Joke>
<Joke :text="joke"></Joke>
<Joke :text="joke"></Joke>
</div>
</template>
<script>
import {onBeforeMount, ref} from "vue";
import Joke from "./Joke";
export default {
name: "JokeProvider",
components: {Joke},
setup() {
let joke = ref("");
onBeforeMount(() => {
getJoke();
});
function getJoke() {
fetch("http://api.icndb.com/jokes/random")
.then(response => response.json())
.then(result => {
joke.value = result.value.joke
})
}
return {joke, getJoke}
}
};
</script>
<style scoped>
.jokes {
display: flex;
flex-direction: row;
justify-content: space-between;
}
button {
width: 100%;
margin-bottom: 10px;
}
</style>
The joke value comes from the props. The component has also an input text who allow to set up the replacement text.
<!-- Joke.vue -->
<template>
<div class="container">
<input type="text" v-model="replacement">
<div>{{text}}</div>
</div>
</template>
<script>
import {computed, ref, unref} from "vue";
export default {
name: "Joke",
props: {
text: {
type: String,
default: ''
}
},
setup(props) {
let replacementRef = ref("Chuck Norris");
let replacement = computed({
get: () => replacementRef.value,
set: val => replacementRef.value = unref(val)
});
let text = computed(() => props.text.replace("Chuck Norris", replacementRef.value))
return {text, replacement}
}
};
</script>
<style scoped>
.container {
margin-left: 10px;
}
</style>
Then we use the JokeProvider in App component
<template>
<JokeProvider></JokeProvider>
</template>
<script>
import JokeProvider from "./JokeProvider";
export default {
name: 'App',
components: {JokeProvider},
};
</script>
Sibling using parent as source of truth
This time the data still coming from provider but is stored in the App component.
The Joke.vue
component stays the same.
The JokeProvider has changed, instead of keeping the joke, it emits a signal with as payload the joke.
<!-- JokeProvider.vue -->
<template>
<button @click="getJoke">Update Joke</button>
</template>
<script>
import {onMounted} from "vue";
import Joke from "./Joke";
export default {
name: "JokeProvider",
components: {Joke},
setup(props, {emit}) {
onMounted(() => {
getJoke();
});
function getJoke() {
fetch("http://api.icndb.com/jokes/random")
.then(response => response.json())
.then(result => {
emit('input', result.value.joke)
})
}
return {getJoke}
}
};
</script>
<style scoped>
button {
width: 100%;
margin-bottom: 10px;
}
</style>
The App component gets another role, to store the joke data.
<template>
<JokeProvider @input="handleJoke"></JokeProvider>
<div class="jokes">
<Joke :text="joke"></Joke>
<Joke :text="joke"></Joke>
<Joke :text="joke"></Joke>
</div>
</template>
<script>
import JokeProvider from "./JokeProvider";
import Joke from "./Joke";
import {ref} from "vue";
export default {
name: 'App',
components: {Joke, JokeProvider},
setup() {
let joke = ref("")
function handleJoke(jokeText) {
joke.value = jokeText
}
return {joke, handleJoke}
}
};
</script>
<style>
.jokes {
display: flex;
flex-direction: row;
justify-content: space-between;
}
</style>
Dependency injection
The idea is to don't need the App component part but keep the JokeProvider sibling to Joke components.
To use the pattern "dependency injection" Vue provides two methods.
Provide
This provide
method allow us to register something. It takes two arguments, the first one is the unique identifier, it can be a string but it's prefered to use a Symbol
, see them as an immutable value warranted to be unique.
We register a reference to joke value. With the identifier JokeSymbol
.
// logic.js
import {ref, provide} from "vue";
export const JokeSymbol = Symbol();
export function buildProvider() {
let joke = ref("")
provide(JokeSymbol, joke)
}
Inject
The second hand is to use the data previously registered, it's done through the inject
method. It takes two arguments, the identifier of the data that must be injected and a default value if the identifier match with non value registered.
We get the reactive joke value out of the logic.js
file through the injection with the JokeSymbol
.
Doing:
let joke = inject(JokeSymbol, "");
or
let joke = ref("");
Is the same thing for the calling method. Both will give a reactive value that can be modified.
We extract the JokeProvider logic into a file called jokeProviderLogic.js
.
// jokeProviderLogic.js
import {onMounted, inject} from "vue";
import {JokeSymbol} from "./logic";
export function buildJokeProvider() {
let joke = inject(JokeSymbol, "");
onMounted(() => {
getJoke();
});
function getJoke() {
fetch("http://api.icndb.com/jokes/random")
.then(response => response.json())
.then(result => {
joke.value = result.value.joke
})
}
return {getJoke}
}
Then use it into the JokeProvider
component.
<!-- JokeProvider.vue -->
<template>
<button @click="getJoke">Update Joke</button>
</template>
<script>
import Joke from "./Joke";
import {buildJokeProvider} from "./jokeProviderLogic";
export default {
name: "JokeProvider",
components: {Joke},
setup() {
return buildJokeProvider();
}
};
</script>
<style scoped>
button {
width: 100%;
margin-bottom: 10px;
}
</style>
In the same way we get a reference of joke
variable into the Joke
component.
As this reference is the same as in the JokeProvider
when this one will modified joke
, the joke value will also been modified for Joke
component.
We have created a share reference across components.
<!-- Joke.vue -->
<template>
<div class="container">
<input type="text" v-model="replacement">
<div>{{text}}</div>
</div>
</template>
<script>
import {computed, ref, unref, inject} from "vue";
import {JokeSymbol} from "./jokeProviderLogic";
import {watch} from "../web_modules/vue";
export default {
name: "Joke",
setup() {
let replacementRef = ref("Chuck Norris");
let replacement = computed({
get: () => replacementRef.value,
set: val => replacementRef.value = unref(val)
});
let textInjected = inject(JokeSymbol, "")
let text = computed(() => {
return textInjected.value.replace("Chuck Norris", replacementRef.value)
})
return {text, replacement}
}
};
</script>
<style scoped>
.container {
margin-left: 10px;
}
</style>
The only restriction is that provide
must be called in a Component parent of Component calling the inject
method.
This implies to call buildProvider()
in App
component as it's parent to both Joke
and JokeProvider
components.
<!-- App.vue -->
<template>
<JokeProvider></JokeProvider>
<div class="jokes">
<Joke></Joke>
<Joke></Joke>
<Joke></Joke>
</div>
</template>
<script>
import JokeProvider from "./JokeProvider";
import Joke from "./Joke";
import {buildProvider} from "./logic";
export default {
name: 'App',
components: {Joke, JokeProvider},
setup() {
buildProvider()
}
};
</script>
<style>
.jokes {
display: flex;
flex-direction: row;
justify-content: space-between;
}
</style>
Emulate a Store
We've gone far into the rabbit hole. Let's finish with a Store emulation.
The goal is to use one source of truth for all components as nested as we want.
I've written a little project to test this.
I'll explain the complex parts.
Store
// store.js
import {reactive} from "vue";
let state = {
joke : "",
jokeDisplays: []
}
const mutations = {
updateJoke(joke) {
store.state.joke = joke
},
removeJokeDisplay(index) {
store.state.jokeDisplays.splice(index, 1)
},
addJokeDisplay() {
store.state.jokeDisplays.push(Symbol())
}
}
export const StoreSymbol = Symbol();
export const store = reactive({
state,
mutations
})
The store feels like a Vuex store, there is a state and mutations applied on in it.
Technical Store Component
This renders nothing, it's job is just to provide the Store to its children.
<!-- Store.vue -->
<template>
<slot></slot>
</template>
<script>
import {store, StoreSymbol} from "../store";
import {provide} from "vue";
export default {
name: "Store",
setup() {
provide(StoreSymbol, store)
}
};
</script>
Wrap our App with a Store
<!-- App.vue -->
<template>
<store>
<Jokes></Jokes>
</store>
</template>
<script>
import Jokes from "./components/Jokes";
import Store from "./components/Store";
export default {
name: 'App',
components: {Store, Jokes}
};
</script>
Doing this allow to use our store in all child components.
// jokeProviderLogic.js
import {inject, onMounted} from "vue";
import {StoreSymbol} from "../store";
export default function buildJokeProvider() {
let store = inject(StoreSymbol, null);
if (!store) return;
onMounted(() => {
getJoke();
});
function getJoke() {
fetch("http://api.icndb.com/jokes/random")
.then(response => response.json())
.then(result => {
store.mutations.updateJoke(result.value.joke)
})
}
return {
getJoke,
addJoke: store.mutations.addJokeDisplay
}
}
Or in a nested Component
<template>
<button @click="remove">Delete</button>
</template>
<script>
import {inject} from "vue";
import {StoreSymbol} from "../store";
export default {
name: "DeleteButton",
props: {
index: Number
},
setup(props) {
const store = inject(StoreSymbol, null);
if (!store) return;
function remove() {
store.mutations.removeJokeDisplay(props.index)
}
return {
remove
}
}
};
</script>
<style scoped>
button {
background-color: lightcoral;
margin-left: 10px;
}
</style>
You can add a "joke display" and remove it. But all the store is handle outside. So it's a success 🤩.
Conclusion
It was a long journey through the composition API, but I totally love it. I can't wait to use it in production!
Phiew, there was a lot of concept to cover and there many like the typescript support ( maybe in a next post ).
I hope I managed to keep the things understandable. See you soon for more articles on JS or something else ❤️.
Ce travail est sous licence CC BY-NC-SA 4.0.