Vue defineModel turns a custom v-model component from a prop, an emit and a computed getter-setter into one line. It has been the recommended way to write component v-model since Vue 3.4. This post rewrites a real wrapper around Quasar's QInput with it, then covers the parts that trip people up: types, default values, several models on one component, and custom modifiers.
Most apps end up wrapping QInput. You want the same label style, the same outlined dense look and the same validation everywhere. Before Vue 3.4, an email field wrapper looked like this:
<!-- EmailField.vue, before -->
<template>
<q-input
:model-value="modelValue"
@update:model-value="emit('update:modelValue', $event)"
type="email"
label="Email"
outlined
dense
lazy-rules
:rules="[(v) => !!v || 'Email is required']"
/>
</template>
<script setup lang="ts">
defineProps<{ modelValue: string }>();
const emit = defineEmits<{ "update:modelValue": [value: string] }>();
</script>It works, but every wrapper repeats the same three parts: declare modelValue, declare update:modelValue, and wire both by hand. If you want a local v-model inside the component, you add a computed with a get and a set on top.
Here is the same component with defineModel:
<!-- EmailField.vue, after -->
<template>
<q-input
v-model="email"
type="email"
label="Email"
outlined
dense
lazy-rules
:rules="[(v) => !!v || 'Email is required']"
/>
</template>
<script setup lang="ts">
const email = defineModel<string>({ required: true });
</script>The parent doesn't change at all:
<EmailField v-model="form.email" />defineModel returns a ref. Reading email.value gives you what the parent passed. Assigning to it sends the new value back to the parent. Because it is a ref, you can hand it straight to QInput's own v-model.
defineModel is a compiler macro, like defineProps. The Vue compiler expands it into exactly the code you used to write:
modelValue, which the ref reads from;update:modelValue, which is emitted when you assign to the ref.So defineModel isn't a new kind of binding. The parent still passes a prop and listens for an event. The difference is that you no longer write that plumbing, and you can't get the event name wrong.
There is one consequence of this. Assigning email.value = "..." emits the event. Changing a property inside an object model does not. If your model is an object, such as an address, assign a new object (address.value = { ...address.value, city }) instead of setting address.value.city. The second form edits the parent's object in place without telling the parent anything changed.
defineModel takes the model's type as a type argument:
const email = defineModel<string>();
// Ref<string | undefined>
const email = defineModel<string>({ required: true });
// Ref<string>Without required: true, Vue has to assume the parent might not pass a value, so the type includes undefined. Mark the model required when the component makes no sense without it, which is true for most form fields.
The object you pass is the same object you would give defineProps for that prop, so required, default and type all work.
You can give a model a default:
const quantity = defineModel<number>({ default: 1 });Be careful with this. If the parent binds v-model to a ref that is still undefined, the child shows 1 but the parent still holds undefined. The two sides now disagree until the user changes the value. The Vue docs call this out as a warning.
The safe pattern is to set the starting value in the parent, where the data lives, and to use default only for models the parent may leave out entirely.
Here is a quantity field that follows that rule. It uses QInput's number handling, which needs both type="number" and the .number modifier:
<!-- QuantityField.vue -->
<template>
<q-input
v-model.number="quantity"
type="number"
label="Quantity"
outlined
dense
min="1"
/>
</template>
<script setup lang="ts">
const quantity = defineModel<number>({ required: true });
</script><!-- Parent -->
<script setup lang="ts">
import { ref } from "vue";
const quantity = ref(1);
</script>
<template>
<QuantityField v-model="quantity" />
</template>A component can expose several models. Give each one a name as the first argument:
<!-- DateRangeField.vue -->
<template>
<div class="row q-col-gutter-sm">
<div class="col-6">
<q-input v-model="from" type="date" label="From" outlined dense stack-label />
</div>
<div class="col-6">
<q-input v-model="to" type="date" label="To" outlined dense stack-label />
</div>
</div>
</template>
<script setup lang="ts">
const from = defineModel<string>("from", { required: true });
const to = defineModel<string>("to", { required: true });
</script>The parent binds each one with an argument:
<DateRangeField v-model:from="filters.from" v-model:to="filters.to" />Each name becomes its own prop and event: from with update:from, and to with update:to. Names written in camelCase in defineModel are bound in kebab-case in the parent, as with any prop, so defineModel("startDate") pairs with v-model:start-date.
Use named models when the values belong together but are stored separately. If they are always read together, a single object model is often simpler.
Built-in modifiers like .trim and .number exist for native inputs. You can define your own for a component. Say you want emails stored in lowercase, but only on some forms:
<EmailField v-model.lowercase="form.email" />Destructure defineModel to get the modifiers, and use a set transformer to change the value on its way out to the parent:
<!-- EmailField.vue, with a modifier -->
<template>
<q-input
v-model="email"
type="email"
label="Email"
outlined
dense
lazy-rules
:rules="[(v) => !!v || 'Email is required']"
/>
</template>
<script setup lang="ts">
const [email, modifiers] = defineModel<string, "lowercase">({
required: true,
set(value) {
return modifiers.lowercase ? value.toLowerCase() : value;
},
});
</script>The second type argument lists the modifiers the component accepts, so modifiers.lowercase is typed. There is also a get transformer, which changes the value on its way in from the parent.
Named models take modifiers too. v-model:from.trim="..." arrives as the modifiers of the from model when you write const [from, fromModifiers] = defineModel("from").
The wrapper above hard-codes label="Email". What if one form needs a different label, or a hint? You don't have to declare those props. A component with a single root element passes any attribute it doesn't declare to that root, so this works:
<EmailField v-model="form.email" label="Work email" hint="We never share it" />label and hint land on the inner QInput, and the one passed by the parent wins over the one in the template. modelValue and update:modelValue are declared by defineModel, so they are not passed through a second time.
This is what makes thin wrappers cheap to write. The wrapper owns the model and the house style. Everything else flows through to Quasar.
Once you have an EmailField, a QuantityField and a DateRangeField, give them a home. A components/form/ folder works well, next to the composables that load and save the form. The post on structuring Quasar projects covers how to split components, composables and pages as the app grows.
Use defineModel when the component edits a value that the parent owns. Don't use it when:
change event and let the parent decide whether to update the value.provide/inject fits better than passing v-model through several levels.Which Vue version do I need? Vue 3.4 or later.
Does defineModel work with the Options API? No. It is a <script setup> macro. In the Options API you still declare props and emits.
Can I use defineModel with Quasar components? Yes. The ref it returns works with v-model on any Quasar form component: QInput, QSelect, QToggle, QCheckbox and the rest.
Wrapped inputs are where defineModel pays off first. For ready-made field designs (amount fields, email fields with icons and hints) that you can wrap the same way, see the Inputs Block. Three of its Components are Free to copy.