Computed Model
Intro
Two-way data binding is one of Vue's core patterns. This is best reflected by the v-model
directive. It allows us to pass data down to child components and listen to events from the child in the parent. This process abstracts two wirings, the data prop, and the event listener, with a single directive.
I curious to see if we can we achieve the same two-way data binding through a computed property?
Example
const dev = ref('vue')
const ex = ref('nuxt')
const devex = computed(() => `${toValue(dev)}${toValue(ex)}.com`)
console.log(toValue(devex))
// ^ this would output `vuenuxt.com` as expected
Any change on dev
or ex
would trigger a recomputation of devex
. This is what computer properties are known for; a change in any of its dependencies will trigger the computed logic in the callback to re-run and return an updated value. That's one-way data binding deps -> computed logic
.
Problem
But how can we change the value of the dependencies through the computed property itself, computed -> deps
? If we try to assign a new value to devex,
we will get a Vue Warning because computed properties are read-only by default. This makes sense because we have no way of knowing how to match the input to the schema of the dependencies.
const dev = ref('vue')
const ex = ref('nuxt')
const devex = computed(() => `${toValue(dev)}${toValue(ex)}.com`)
devex.value = 'vue some'
// ^ [Vue warn] Write operation failed: computed value is readonly
Insight
We are used to passing an anonymous function as an argument into the computed function. Still, we can also pass an object with getters and setters to extend its functionality. The getter logic shapes the output. The setter logic allows us to do whatever we want with the input. This change not only turns off the read-only limitation but opens the path for two-way binding. deps <-> computed
const dev = ref('vue')
const ex = ref('nuxt')
type Devex = `${string} ${string}`
const devex = computed({
get() {
return `${toValue(dev)}${toValue(ex)}.com`
},
set(newValue: Devex) {
[dev.value, ex.value] = newValue.split(' ')
}
})
setTimeout(() => {
devex.value = 'vue some'
}, 3000)
Notice: How we define a more constrained string type for our input in the ArrayExample.vue to ensure the
newValue.split(' ')
in the setter will work as expected.
Mechanism
After the timer runs, we set the value of devex
(red) with vue some
(green), which triggers the setter
(purple) path. The setter logic changes the value of the refs dev
(blue) and ex
(blue). This update triggers a re-run of the getter
(purple) path for devex
(red) that outputs a newly computed value of vuesome.com
(orange).
That's computed two-way data binding. How vuesome is that!
Paths:
- setter: dotted lines
- getter: solid lines
Applications
V-Model
This application allows us to use v-model
through a computed property in the usual parent-child component relationship. We emit events in the child component because we do not want to mutate props. This slightly increases the wiring in the child component but makes it easier to reuse this component in other parts through the standard v-model pattern.
We change the value of the model using three timeouts:
- sets a new value for the computed property in the parent component
- emits a new value from the child component, which is picked up by the computed property in the parent
- mutates a dependant ref directly in the parent component
type DevexInput = `${string} ${string}`;
export const useComputedModel = () => {
const dev = ref('vue');
const ex = ref('nuxt');
return {
dev: readonly(dev),
ex,
computedModel: computed({
get() {
return `${toValue(dev)}${toValue(ex)}.com`;
},
set(newValue: DevexInput) {
[dev.value, ex.value] = newValue.split(' ');
},
}),
};
};
Provider
This application is a more powerful but less standard alternative to the v-model approach presented above. Instead of binding through a v-model directly on child components, we provide
the computed model from the parent to all its nested components. This approach allows two-way data binding in all child components without having to prop-drill or wire event listeners along the way.
For simplicity, I use three levels for this example:
-| Parent.vue
---| Child.vue
-----| GrandChild.vue
We change the value of the model using three timeouts:
- sets a new value for the computed property in the child component.
- sets a new value for the computed property in the grandchild component.
- mutates a dependant ref directly in the parent component.
type DevexInput = `${string} ${string}`
export const useComputedModel = () => {
const dev = ref('vue')
const ex = ref('nuxt')
const computedModel = computed({
get() {
return `${toValue(dev)}${toValue(ex)}.com`
},
set(newValue) {
[dev.value, ex.value] = newValue.split(' ')
}
})
return {
dev,
ex,
computedModel,
}
}