jkonieczny
Posted on February 1, 2024
It was available as a macro for some time, experimental in 3.3, and now stable in 3.4. An amazing feature that shortens your code a lot and prevents you from having to think of a name used inside the component for the Ref
proxy made with useVModel
or manual computed
.
This article was written with Vue v3.4.15, things may change in newer versions.
What is it and why is it awesome?
Previously, if we wanted to make a prop that would sync its value, we had to write this monstrosity:
const props = defineProps<{
modelValue: Item
}>();
const emit = defineEmits<{
(e: 'update:modelValue', v: Item): void
}>();
const innerValue = useVModel(props, 'modelValue', emit);
And without useVModel
:
const innerValue = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
});
We have to repeat the name "modelValue"
3 times (or 4 without useVModel
). If we wanted to use it in our template, it would have to have a different name from the prop (that's why I used innerValue
). Now it all shortens to this:
const modelValue = defineModel<Item>();
And that's it. 7 generic lines of code were reduced into one. We can use the same name for variable and prop, and the type here will be Ref<Item|undefined>
, we'll fix it later.
Set custom name
We can have multiple v-model
on a component, so each has to have a different name. The default name is "modelValue"
, but if we want to specify the name of the model, we have to pass it through the first argument:
const innerValue = defineModel<Item>("myValue");
We can define more than one model that way:
const innerValue = defineModel<Item>(); // default modelValue
const viewType = defineModel<ViewType>("viewType");
The compiler will warn you if two models have the same name, no worries.
Set the default value or required
The second (or the first, if we are fine with "modelValue"
as the name) argument is an object to set up the prop. We can set default value with default
field, which is either a primitive value, or a function that returns the instance of object (for object types):
// when it's object
const innerValue = defineModel<Item>({ default: () => newItem() );
// when it's a primitive
const primitiveValue = defineModel<number>(
"count", { default: 10 }
);
And field required
to make the field... required, remove the | undefined
from type:
const innerValue = defineModel<Item>({ required: true });
And now we have Ref<Item>
instead of Ref<Item | undefined>
.
What if v-model isn't passed?
If we use defineModel
, but we won't pass any prop (via either v-model
or just by normal prop), the Ref
will be undefined
by default (unless we provide default
), but we can still mutate the value and we won't lose any changes. It acts like a wrapper with a local copy:
const props = defineProps<{
modelValue?: Item
}>();
const emit = defineEmits(['update:modelValue'])
const innerValue = ref(props.modelValue);
watch(() => props.modelValue, (newValue) => {
if (newValue != innerValue.value) {
innerValue.value = newValue;
}
});
watch(innerValue, (newValue) => {
emit('update:modelValue', newValue);
});
The actual implementation is of course a bit more complicated, but no need to care about it.
Reactivity
Here comes the problematic one... While we should not mutate props (and their fields), we should emit new values. Therefore, we shouldn't assign anything to innerValue.value
's fields, but instead overwrite the innerValue.value
which will cause the emit:
innerValue.value = {
...innerValue.value,
[key]: value
};
However, if the prop value was reactive, therefore the innerValue.value
's fields will be reactive as well. If you don't care much about good practices, you can easily use v-model="innerValue.field"
for your input and it will work well.
Mutating fields won't call the emit.
You can of course use toProxy
(explained here) to keep it clean without having to manually write emits.
Modifiers! And what the...
Now something "new": we can pass custom modifiers to the v-model
, just like we do for input
s: after a dot. It's pretty easily explained in the official docs, so in short: we can now pass modifiers like v-model.trim.lower="myValue"
and get them with:
const [modelValue, modelModifiers] = defineModel({
set(value) {
let toEmit = value;
if (modelModifiers.trim) {
toEmit = toEmit.trim();
}
if (modelModifiers.lower) {
toEmit = toEmit.toLowerCase();
}
return value
}
});
Basically, any part after a dot lands in modelModifiers
as a boolean field (either true
or undefined
). modelModifiers
is not reactive, which might be useful.
Power of static modifiers
All props are reactive, which is nice, but also... annoying, especially if you want to configure the component to act in a way that it shouldn't ever change. It won't, but you can't just ignore the props changes. Okay, you can, but it's not a nice way.
What I mean, is that you can pass a bunch of strings, that you can be sure will never change during the lifetime of the component, therefore there is no need to care about them changing!
For example:
<ViewComponent type="horizontal" v-model:context="context">
</ViewComponent>
In this template, we know that type
will never change, but inside the component type
will be reactive, eslint
will not allow us to read it losing the reactivity (unless you disable that rule, but don't).
We can now do this:
<ViewComponent v-model.horizontal:context="context">
</ViewComponent>
And inside:
type Options = "horizontal" | "vertical" | "scaled";
const [context, modifiers] = defineModel<ContextType, Options>()
const viewType = modifiers.horizontal ? "horizontal" : "vertical";
const scaled = modifiers.scaled;
if (viewType == "horizontal") {
// constant sets for horizontal with no need to care
// that the `viewType` would ever change
}
It's just an empty example presenting the idea and maybe in the future it won't be working that way — maybe modifiers will become reactive? We don't know, I'm just presenting a quick idea, don't take it too seriously.
What's under the hood?
Okay, but there were no breaking changes here, defineModel
is "just" another macro that is compiled into an "old" component defining method via defineComponent
. What happens under the hood and documentation doesn't mention it (though it should!), is that there is added another prop, and it's kinda tricky.
In the simplest example with the default modelValue
, the SFC compiler produces something like this:
const __sfc__ = _defineComponent({
__name: 'Comp',
props: {
"modelValue": { type: String, ...{
} },
"modelModifiers": {},
},
emits: ["update:modelValue"],
});
In case where we also have defineProps
with one prop called viewType
:
const __sfc__ = _defineComponent({
__name: 'Comp',
props: /*#__PURE__*/_mergeModels({
viewType: { type: String, required: true }
}, {
"modelValue": { type: String, ...{
} },
"modelModifiers": {},
}),
});
In short, _mergeModels
just merges those two objects. The first one will contain props from defineProps
, second the ones produced by defineModel
(all of them).
We can notice the new prop called modelModifiers
, that's the one produced by defineModel
. It's always produced and added, whether we use them or not, probably because otherwise if we try to pass some modifiers without them being defined, they would unnecessarily taint $attrs
.
So, underneath, modifiers are reactive, but it's obfuscated in the code. But what's the problem here?
Vue adds new hidden props without informing us directly. If we have a modelValue
, there is another prop called modelModifiers
. Also, this name is always a special case.
If we create any other name, there will be defined props called name
and nameModifiers
, which also causes a conflict if we have one model called modelValue
and other... model
.
const modelValue = defineModel<string>();
const model2 = defineModel<string>("model");
const viewType = defineModel<string>("viewType");
Will produce this:
props: {
"modelValue": { type: String },
"modelModifiers": {},
"model": { type: String },
"modelModifiers": {},
"viewType": { type: String },
"viewTypeModifiers": {},
},
I don't think I need to say that this is just wrong... modelModifiers
appears twice, I hope they will write a name collision detector here, or, since the fact of another prop is being created, they will make the names more unique.
In short: with defineModel
you need to remember that a prop with the suffix Modifiers
is virtually added. Also, don't use props that end with "Modifier"
yourself, or be careful with that.
From the renderer's viewpoint
If we look at the compiled template, there will be no surprise, that the modifiers are passed just like other attributes/props:
_createVNode($setup["Comp"], {
modelValue: $setup.msg,
"onUpdate:modelValue": _cache[1] || (_cache[1] = $event => (($setup.msg) = $event)),
modelModifiers: { trim: true, lower: true },
class: "item-renderer",
viewType: $setup.msg,
"onUpdate:viewType": _cache[2] || (_cache[2] = $event => (($setup.msg) = $event)),
viewTypeModifiers: { horizontal: true }
}, null, 8 /* PROPS */, ["modelValue", "viewType"])
Again, in short: no magic here. An object literal is passed. Anyway, attributes and event listeners also land there. But events always have the on*
prefix.
Summary
Among other new features that Vue 3.4 brought, defineModel
is the most noticeable one (I know that performance is important, but it doesn't change the way you write your code), which will make writing components easier and faster. But it also brings a new feature.
Modifiers are awesome, but you need to remember it adds virtual props.
With that new feature, code will be shorter, and easier to read and you'll have more possibilities.
EDIT
Okay, it looks like the official docs were supplemented with missing information about additional props being added, when using modifiers, in the expanding Pre 3.4 Usage
sections.
vue
Posted on February 1, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.