50% off for the first 50 customers. Only 10 left. Use code
40/50 claimed
See pricing →
November 1, 2026

Vue defineModel: v-model Without the Boilerplate

Use Vue defineModel to replace prop and emit pairs in custom v-model components: typing, defaults, named models and modifiers, shown on a wrapped QInput.
Vue
Vue 3
defineModel
Quasar

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.

The old way: a prop and an emit

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.

Vue defineModel in one line

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.

How defineModel works

defineModel is a compiler macro, like defineProps. The Vue compiler expands it into exactly the code you used to write:

  • a prop named modelValue, which the ref reads from;
  • an event named 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.

Typing defineModel

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.

Default values and the desync trap

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>

Named models: more than one v-model

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.

defineModel modifiers

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").

Passing other props through to QInput

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.

Where these wrappers live

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.

When defineModel is the wrong tool

Use defineModel when the component edits a value that the parent owns. Don't use it when:

  • The component only displays a value. A plain prop is clearer.
  • The parent should confirm a change first. For example, a status picker that asks the server before switching. Emit a change event and let the parent decide whether to update the value.
  • The value is shared across distant components. A store or provide/inject fits better than passing v-model through several levels.

Questions about Vue defineModel

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.

Next step

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.