DEV Community

jkonieczny
jkonieczny

Posted on

Vue's new `defineModel`

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);
Enter fullscreen mode Exit fullscreen mode

And without useVModel:

const innerValue = computed({
  get: () => props.modelValue,
  set: (v) => emit('update:modelValue', v),
});
Enter fullscreen mode Exit fullscreen mode

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>();
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

We can define more than one model that way:

const innerValue = defineModel<Item>(); // default modelValue
const viewType = defineModel<ViewType>("viewType");
Enter fullscreen mode Exit fullscreen mode

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 }
);
Enter fullscreen mode Exit fullscreen mode

And field required to make the field... required, remove the | undefined from type:

const innerValue = defineModel<Item>({ required: true });
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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 inputs: 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
  }
});
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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"],
});
Enter fullscreen mode Exit fullscreen mode

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": {},
  }),
});
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

Will produce this:

props: {
  "modelValue": { type: String },
  "modelModifiers": {},
  "model": { type: String },
  "modelModifiers": {},
  "viewType": { type: String },
  "viewTypeModifiers": {},
},
Enter fullscreen mode Exit fullscreen mode

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"])
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)