Vue defineModel: greate and terrible

· Sergej Atamantschuk

Vue is a UI library that supports two-way binding out of the box. That means you can write components which not only can accept readonly props but also are illegible to emit events to parent components to update the props. Two-way binding has a long history in Vue and makes a lot of transformations until Vue 3.4. Anyway, it still has some pitfalls, can be a source of bugs and confusion and is one of the most controversial topics in Vue.

Vue 3.4 v-model

But let’s start with the good parts. Vue 3.4 introduced a new way to define v-model in the components. With a defineModel macro we now have to write significantly less code to create a component with v-model. Let’s see an example:

<template>
  <v-textfield v-model="model"/>
</template>

<script setup lang="ts">
  const model = defineModel<string>({
    required: true,
  });
</script>

And now we can use our custom MyTextInput.vue component with v-model in the parent component, just like before Vue 3.4, myValue will be in sync with the model in the child component:

<template>
  <my-text-input v-model="myValue" @update:modelValue=" do stuff with updated value"/>
</template>

<script setup lang="ts">
  import MyTextInput from '@/components/MyTextInput.vue';
  import {ref} from 'vue';

  const myValue = ref<string>('');
</script>
MyTextInput.vue before Vue 3.4 to feel the difference

<template>
  <v-textfield v-model="model"/>
</template>

<script setup lang="ts">
  const {modelValue} = defineProps<{
    modelValue: string;
  }>();
  const emit = defineEmits<{
    "update:modelValue": [string];
  }>();

  const computedModelValue = computed({
    get: () => modelValue,
    set: (val) => emit("update:modelValue", val),
  });

  watch(
    () => modelValue,
    () => {
      computedModelValue.value = modelValue;
    }
  );
</script>

Pretty nice, right! You should note a few things here:

  • defineModel is a macro that creates and syncs a prop with the parent state and is accessible in the child as the model ref
  • defineModel is basically a syntactic sugar for the defineProps and defineEmits with the update:modelValue event, under the hood it works the same way as before Vue 3.4
  • We don’t have to define the emit event, defineModel does it for us
  • We can directly change the model ref, and it will be reflected in the parent component
  • Directly change the model ref will emit the update:modelValue event to the parent component

Objects and defineModel

But what if we want to use two-way binding with objects? Mostly you will write custom v-model for rather complex components like forms, color pickers, or other components that have a complex state. Or you might want to write a component for an array v-model.

You know, in JS anything is an object and objects are copied by reference. This is where defineModel starts to be a bit tricky.

Imagine below is your brand-new form component written with defineModel. Let’s see which cascade of issue the below code produces and let us try to resolve it:

<template>
  <v-textfield v-model="model.prop1"/>
  <v-textfield v-model="model.prop2"/>
</template>

<script setup lang="ts">
  type Model = { prop: string, prop2: string }

  const model = defineModel<Model>({
    required: true,
  });
</script>
  • model value is mutated - we have to differentiate between value change and mutation. Modifying the prop model.prop1 we mutate the object by reference. In general, we mutate the object passed in by the parent “avoiding” Vue internal mechanisms. Before Vue 3.4, Vue would have insulted us for something like this in the console.warn. Now it seems to be intended behavior according to the corresponding RFC .
  • update:modelValue event is not emitted - following from the above point, prop mutation isn’t considered as a model value change and the update:modelValue event is not emitted. This is very error-prone and not consistent as the v-model behavior.
    • changing object value is dangerous - to fix the above issue we have to change the object value like this:

      model.value = { ...model.value, prop1: 'new value' }
      

      Apart from the limited Developer Experience, this is also very error-prone. If your component is more complex and the object is changed like this in different places in the same tick, the chances are high that some changes may be overwritten as we are replacing the whole object each time.

      • define update:modelValue event - to fix the above issue we have to define the update:modelValue event and emit it manually each time we mutate the object props. Now we get the update:model-value on parent, but still mutating the object by reference.
        const emit = defineEmits<{
          "update:modelValue": [Model];
        }>();
        ...
        model.value.prop1 = 'new value';
        // still have to define the event manually,
        // kinda loosing the advantages of defineModel macro
        emit('update:modelValue', model.value);
        

If you want to experience the issues cascade on your own, feel free to use this play ground. There you can get a few practical examples.

It must be mentioned, the issues covered above are not exclusive to the defineModel macro. They also apply to the v-model and reactivity in general bevor Vue 3.4. The defineModel macro just adds a new layer of abstraction and covers some important pitfalls we developers should be aware of.

The recipe for two-way binding

  • Primitives — just go for it, anything works like expected
  • Objects/arrays
    • 🚫 model.value.prop1 = "New value" / model.value.push("New value") - bad, mutated by reference, no event emitted, avoid that
    • model.value = { ...model.value, prop1: "New value" } / model.value = [] - better compromise, value is changed, event emitted. Should be your choice in most cases. But, enjoy with caution where you are sure this will not cause any race conditions updating the props
    • ❕mutate value and emit event manually - can’t always recommend it due to verbosity, the loos of ergonomics of defineModel macro and the mutation by reference. Use in complex cases where the v-model may be updated (* changed by value*) simultaneously in different places in one tick

One last edge case that might be a source of hours of debugging. Value changes on v-model in the component accepting and handling the v-model are only applied in the next tick! Meaning you cant directly work with value newly assigned to the v-model in the same tick.

const onModelChange = (newValue: string) => {
  model.value = { prop1: newValue };
  doSomethingWithNewModelValue(model.value) // 🚫 this will not work as expected, model.value is not yet updated and still holds the old value
}

And that’s actually a good thing, it’s a signal that the v-model works as specified and Vue reactivity takes over the control first emitting the event to the parent and then updating the value in the child component.

When crafting components with custom v-model, it’s important to keep reactivity quirks in mind to maintain a healthy and predictable codebase. There’s no silver bullet solution for two-way binding. But with this recipe, you’ll dodge the common pitfalls and make your code more robust.