Preface:
In this post, I will be demonstrating a pattern we can apply when using Vue 3’s new Composition API to solve a specific challenge. I won’t be covering all the fundamentals, so a familiarity with the basics of this new API will be helpful for you as a prerequisite.
IMPORTANT: The composition API is additive, it’s a new feature, but it doesn’t and will not replace the good ole “Options API” you know and love from Vue 1 and 2. Just consider this new API as another tool in your toolbox that may come in handy in certain situations that feel a little clumsy to solve with the Options API.
I love Vue’s new Composition API. To me, it feels like Vue’s reactivity system has been freed of the constraints of the component, and I can now use it to write reactive code however I want.
In my experience, once you have wrapped your head around it, it’s a wonderful way to create flexible, reusable code that composes nicely and lets you see how all of the parts and features of your component interact.
I’ll start this post with a little utility that you can use to work with components and v-model
more easily.
Recap: The v-model
directive
If you’ve worked with Vue, you know the v-model
directive:
<input type="text" v-model="localStateProperty">
It’s a great shortcut to save us from typing template markup like this:
<input
type="text"
:value="localStateProperty"
@change="localStateProperty = $event.target.value"
>
The great thing is that we can also use it on components:
<message-editor v-model="message">
This is the equivalent of doing the following:
<message-editor
:modelValue="message"
@update:modelValue="message = $event"
>
However, to implement this contract of a prop and an event, our <message-editor>
component would have to look something like this:
<template>
<label>
<input type="text" :value="modelValue", @input="(event) => $emit('update:modelValue', event.target.value)" >
<label>
</template>
<script> export default {
props: {
'modelValue': String,
}
}
</script>
This seems pretty verbose, however. 🧐
We have to do this because we cannot directly write to the prop. We have to emit the correct event and leave it to the parent to decide how to deal with the update we communicate, because it’s the parent’s data, not that of our <message-editor>
component. So we can’t use v-model
on the input in this case. Bummer.
There are patterns of how to deal with this in the Options API that you may know from Vue 2, but today I want to look at how we can solve this challenge in a neat way with the tools offered by the Composition API.
The challenge: Reduce boilerplate
What we want to achieve is an abstraction that lets us use the same v-model
shortcut on the input, even though we don’t actually want to write to our local state and instead want to emit the correct event. This is what we want the template to look like when we’re done:
<template>
<label>
<input
type="text"
v-model="message"
/>
<label>
</template>
So let’s implement this with the composition API:
import { computed } from 'vue'
export default {
props: {
'modelValue': String,
},
setup(props, { emit }) {
const message = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
return {
message,
}
}
}
Okay, that’s a chunk of new, maybe alien, code. So let’s break it down:
import { computed } from 'vue'
First off, we import the computed() function, which returns a computed ref - a wrapper for a value that is derived reactively from other reactive data, such as our props
.
const message = computed({
get: () => props.modelValue,
set: (event) => emit('update:modelValue')
})
In setup, we create such a computed property, but a special one: our computed prop has a getter and setter, so we can actually read its derived value as well as assign it a new value.
This is how our computed prop behaves when used in Javascript:
message.value
// => 'This returns a string'
message.value = 'This will be emitted up'
// => calls emit('onUpdate:ModelValue', 'This will be emitted up')
By returning this computed prop from the setup()
function, we expose it to our template. And there, we can now use it with v-model
to have a nice clean template:
<template>
<label>
<input
type="text"
v-model="message"
>
<label>
</template>
<script>
import { computed } from 'vue'
export default {
props: {
'modelValue': String,
},
setup(props, { emit }) {
const message = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
return {
message,
}
}
}
</script>
Now the template is pretty clean. But at the same time, we had to write a bunch of boilerplate in setup()
to achieve this. It seems we just moved the boilerplate from the template to the setup function.
So let’s extract this logic into its own function—a composition function—or for short: a “composable”.
Turn it into a composable
A composable is just a function that we use to abstract some code out from our setup
function. Composables are the strength of this new Composition API and a core principle that allows for better code abstraction and composition.
This is what we are going for:
📄 modelWrapper.js
import { computed } from 'vue'
export function useModelWrapper(props, emit) {
/* implementation left out for now */
}
📄 MessageEditor.vue
import { useModelWrapper } from '../utils/modelWrapper'
export default {
props: {
'modelValue': String,
}
setup(props, { emit }) {
return {
message: useModelWrapper(props, emit),
}
}
}
Notice how the code in our setup()
function was reduced to a one-liner (if we assume we are working in a nice editor that can add the import for useModelWrapper
automatically for us, like VS Code).
How did we achieve that? Well, essentially all we had to do was copy and paste the code from setup
into this new function! This is what it looks like:
📄 modelWrapper.js
import { computed } from 'vue'
export function useModelWrapper(props, emit) {
return computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
}
Okay, that was straightforward, but can we do better? Why yes, we can!
Though to understand in what way we can, we will make a short detour back to how v-model
works on components…
In Vue 3, v-model
can take an additional argument to apply it to a prop other than modelValue
. This is useful if the component wants to expose more than one prop as a target for v-model
v-model:argument
<message-editor
v-model="message"
v-model:draft="isDraft"
/>
The second v-model could be implemented like this:
<input
type="checkbox"
:checked="draft",
@change="(event) => $emit('update:modelValue', event.target.checked)"
>
So verbose template code again. Time to adjust our new composition function so that we can reuse it for this case as well.
To achieve that, we want to add a second argument to the function that specifies the name of the prop that we actually want to wrap. Since modelValue
is the default prop name for v-model
, we can use it as the default for our wrapper’s second argument as well:
📄 modelWrapper.js
import { computed } from 'vue'
export function useModelWrapper(props, emit, name = 'modelValue') {
return computed({
get: () => props[name],
set: (value) => emit(`update:${name}`, value)
})
}
That’s it. Now we can use this wrapper for any v-model prop.
So your final component would look like this:
<template>
<label>
<input type="text" v-model="message" >
<label> <label>
<input type="checkbox" v-model="isDraft"> Draft
</label>
</template>
<script>
import { useModelWrapper } from '../utils/modelWrapper'
export default {
props: {
modelValue: String,
draft: Boolean
},
setup(props, { emit }) {
return {
message: useModelWrapper(props, emit, 'modelValue'),
isDraft: useModelWrapper(props, emit, 'draft')
}
}
}
</script>
Where to go from here
This composable is not only useful when we want to map a modelValue
prop onto an input in our template. We can also use it to pass the computed ref to other composables that expect a ref they can assign a value to.
By first wrapping the modelValue
prop like we did above, the second composition function can be unaware of the fact that we actually don’t deal with a piece of local state. We abstracted away that implementation detail in our nice little useModelWrapper
composable, and so the other composable can treat it as local state.
“Type fast or lose”
As an admittedly silly example, we have a composable called useMessageReset
. It will reset your message to an empty string after you stopped typing for 5 seconds. It goes like this:
function useMessageReset(message) {
let timeoutId
const reset = () => {
if (timeoutId) {
clearTimeout(timeoutId)
}
timeoutId = setTimeout(() => (message.value = ''), 5000)
watch(message, () => message.value !== '' && reset())
}
}
This composable uses watch(), another feature of the Composition API.
- Whenever the message changes, the callback function in the second argument will run.
- If the message is not empty, it will (re)start the timeout that will reset the message to an empty value after 5 seconds.
Notice how this function expects to receive a ref that it can watch and assign a value to.
We would have problems using this with our modelValue
prop since we can’t directly write to it. But using useModelWrapper
, we can provide a writable computed ref to this composable anyway:
import { useModelWrapper } from '../utils/modelWrapper'
import { useMessageReset } from '../utils/messageReset'
export default {
props: { modelValue: Boolean },
setup(props, { emit }) {
const message = useModelWrapper(props, emit)
useMessageReset(message)
return { message }
}
}
Notice how this composable is unaware of the fact that message
is actually emitting an event for the parent component’s v-model
. It can just assign to .value
as if we passed it a normal ref().
Also notice how the rest of our functionality is unaffected by this. We could still use message
in our template, or use it in another way.
Final thoughts
The composition API is a great, flexible tool coming to every Vue developer that wants to use it. The stuff shown above it just the tip of the iceberg.