---
title: "v-model on your own components with defineModel() · vuejs.school"
description: "Build a Stepper that works with v-model, first the long way with a prop and an emit, then in one line with defineModel(), plus named models and modifiers."
canonical_url: "https://vuejs.school/community/blog/v-model-on-your-own-components-with-definemodel"
meta:
  "og:description": "Build a Stepper that works with v-model, first the long way with a prop and an emit, then in one line with defineModel(), plus named models and modifiers."
  "og:image": "https://vuejs.school/_og/d/a_~di1tb2RlbCBvbiB5b3VyIG93biBjb21wb25lbnRzIHdpdGggZGVmaW5lTW9kZWwoKSDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_v-model%2Bon%2Byour%2Bown%2Bcomponents%2Bwith%2BdefineModel()%2Cdescription_~QnVpbGQgYSBTdGVwcGVyIHRoYXQgd29ya3Mgd2l0aCB2LW1vZGVsLCBmaXJzdCB0aGUgbG9uZyB3YXkgd2l0aCBhIHByb3AgYW5kIGFuIGVtaXQsIHRoZW4gaW4gb25lIGxpbmUgd2l0aCBkZWZpbmVNb2RlbCgpLCBwbHVzIG5hbWVkIG1vZGVscyBhbmQgbW9kaWZpZXJzLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy92LW1vZGVsLW9uLXlvdXItb3duLWNvbXBvbmVudHMtd2l0aC1kZWZpbmVtb2RlbCI%2Cs_Tgp2C1yaj0s4jyFo.png"
  "og:image:alt": "v-model on your own components with defineModel() · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "v-model on your own components with defineModel() · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~di1tb2RlbCBvbiB5b3VyIG93biBjb21wb25lbnRzIHdpdGggZGVmaW5lTW9kZWwoKSDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_v-model%2Bon%2Byour%2Bown%2Bcomponents%2Bwith%2BdefineModel()%2Cdescription_~QnVpbGQgYSBTdGVwcGVyIHRoYXQgd29ya3Mgd2l0aCB2LW1vZGVsLCBmaXJzdCB0aGUgbG9uZyB3YXkgd2l0aCBhIHByb3AgYW5kIGFuIGVtaXQsIHRoZW4gaW4gb25lIGxpbmUgd2l0aCBkZWZpbmVNb2RlbCgpLCBwbHVzIG5hbWVkIG1vZGVscyBhbmQgbW9kaWZpZXJzLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy92LW1vZGVsLW9uLXlvdXItb3duLWNvbXBvbmVudHMtd2l0aC1kZWZpbmVtb2RlbCI%2Cs_Tgp2C1yaj0s4jyFo.png"
  "twitter:image:alt": "v-model on your own components with defineModel() · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## What v-model does on an input

On a native input, `v-model` is shorthand. These two lines do the same thing:

```
<input v-model="name" />
<input :value="name" @input="name = ($event.target as HTMLInputElement).value" />
```

Vue writes the ref's value into the field, and writes the field back into the ref when the user types. Two directions, one attribute. That is what makes forms pleasant in Vue: the state lives in one ref, and the field and the ref stay in step.

A component has no built-in `value` and no `input` event, so Vue needs you to say which prop holds the value and which event sends it back. By convention the prop is called `modelValue` and the event is called `update:modelValue`.

## The long form: a prop and an emit

Here is the Stepper written out in full:

*components/Stepper.vue*

```
<script setup lang="ts">
const props = defineProps<{ modelValue: number }>()
const emit = defineEmits<{ 'update:modelValue': [value: number] }>()
</script>

<template>
  <button @click="emit('update:modelValue', props.modelValue - 1)">−</button>
  <span>{{ props.modelValue }}</span>
  <button @click="emit('update:modelValue', props.modelValue + 1)">+</button>
</template>
```

`defineProps` declares a prop called `modelValue` of type `number`. `defineEmits` declares one event whose payload is a number. The buttons never change `modelValue` themselves. They emit the value they would like, and the parent decides.

The parent uses it like an input:

*App.vue*

```
<script setup lang="ts">
import { ref } from 'vue'
import Stepper from './components/Stepper.vue'

const qty = ref(1)
</script>

<template>
  <Stepper v-model="qty" />
  <p>Quantity: {{ qty }}</p>
</template>
```

When the compiler sees `v-model="qty"` on a component, it expands it to `:modelValue="qty"` and `@update:modelValue="value => qty = value"`. That is all `v-model` is on a component: one prop in, one event out.

## Why assigning to the prop fails

The tempting shortcut is to change the prop directly. It does not work, and Vue tells you why:

*components/Stepper.vue**Breaks***

```
<script setup lang="ts">
const props = defineProps<{ modelValue: number }>()

function increment() {
  props.modelValue++
}
</script>
```

TypeScript flags the line first: `Cannot assign to 'modelValue' because it is a read-only property.` If you run it anyway, the parent's `qty` never changes, and in development the browser console shows `[Vue warn] Set operation on key "modelValue" failed: target is readonly.`

Props are read-only on purpose: data flows down from the parent, and changes flow up as events. If a child could change its props, you could never tell where a value came from.

The fix is the emit from the long form:

*components/Stepper.vue**Works***

```
<script setup lang="ts">
const props = defineProps<{ modelValue: number }>()
const emit = defineEmits<{ 'update:modelValue': [value: number] }>()

function increment() {
  emit('update:modelValue', props.modelValue + 1)
}
</script>
```

## defineModel() does it in one line

Since Vue 3.4, `defineModel()` is a compiler macro (a function the compiler replaces at build time, so there is nothing to import) that declares the prop and the event for you and hands back a ref:

*components/Stepper.vue*

```
<script setup lang="ts">
const value = defineModel<number>({ required: true })
</script>

<template>
  <button @click="value--">−</button>
  <span>{{ value }}</span>
  <button @click="value++">+</button>
</template>
```

`value` behaves like any ref. In the template you read and assign it directly; in the script you use `value.value`. The difference from a normal ref is where the data lives. Assigning to `value` emits `update:modelValue`, the parent's `qty` changes, and the new number flows back down. The parent's code does not change: it still writes `<Stepper v-model="qty" />`.

## Make the model required or give it a default

The object you pass to `defineModel()` holds the prop's options. With `required: true`, Vue warns in the console (`Missing required prop: "modelValue"`) when a parent forgets the `v-model`. You can give a default instead:

```
const value = defineModel<number>({ default: 0 })
```

A default also lets the Stepper work on its own. If a parent renders `<Stepper />` with no `v-model`, the ref keeps its value locally, inside the Stepper: it starts at 0 and the buttons still count. That is handy in a demo or a test. The `required: true` version has no starting value in that case: it shows nothing, and the first click turns `undefined` into `NaN`.

Be careful with defaults, though. If the parent binds a ref that is `undefined`, the child shows 0 while the parent still holds `undefined`, and the two disagree until the first click. The [Vue guide to component v-model](https://vuejs.org/guide/components/v-model.html) warns about the same mismatch. For a Stepper, `required: true` is the honest choice: it only makes sense with a value to step.

## Named models for more than one value

A component can expose several values to `v-model` by naming them. Pass the name as the first argument to `defineModel()`, and the parent writes `v-model:name`. Here is a price range with a minimum and a maximum:

*components/PriceRange.vue*

```
<script setup lang="ts">
const min = defineModel<number>('min', { required: true })
const max = defineModel<number>('max', { required: true })
</script>

<template>
  <label>From <input type="number" v-model="min" /></label>
  <label>To <input type="number" v-model="max" /></label>
</template>
```

Each model ref goes straight into an input's `v-model`, so typing updates the parent. The parent binds each model by name, here to refs called `lowest` and `highest`:

*App.vue*

```
<PriceRange v-model:min="lowest" v-model:max="highest" />
```

Each named model is its own prop and its own event: `min` with `update:min`, `max` with `update:max`. An unnamed `defineModel()` is simply the named form with the name `modelValue`.

Name a model after the thing it holds, not after the control: `min` and `max` rather than `leftInput` and `rightInput`. Someone reading the parent should understand `v-model:min="lowest"` without opening the component.

## Add your own modifiers with the tuple form

On native inputs you may have used `v-model.trim` or `v-model.number`. Your components can have modifiers too. Destructure the result of `defineModel()` as a pair: the ref, and an object that says which modifiers the parent used.

The `set` option runs on each assignment and returns the value to send to the parent. This Stepper refuses to go below zero when the parent asks for `.positive`:

*components/Stepper.vue*

```
<script setup lang="ts">
const [value, modifiers] = defineModel<number, 'positive'>({
  required: true,
  set(next) {
    return modifiers.positive ? Math.max(0, next) : next
  },
})
</script>

<template>
  <button @click="value--">−</button>
  <span>{{ value }}</span>
  <button @click="value++">+</button>
</template>
```

In the parent, the modifier goes after `v-model`, as it would on an `<input>`:

*App.vue*

```
<Stepper v-model.positive="qty" />
```

The second type argument, `'positive'`, lists the modifiers you support. `modifiers.positive` is `true` when the parent wrote `.positive` and `undefined` when it did not, and a typo such as `modifiers.postive` is a type error. There is a matching `get` option for transforming the value on the way in.

## Keep the long form for values you convert

Use `defineModel()` by default. It is shorter, it is typed, and it declares the same `modelValue` prop and `update:modelValue` event you wrote by hand in the first version (plus a `modelModifiers` prop for modifiers). Reach for the long form in one situation: when the value the component shows and the value it emits are different things.

A price field is the usual case. The user types `"12.50"` into a text input, but the parent wants the integer `1250`, the price in pence. The component needs to parse on the way out and format on the way in, and a writable computed (a `computed()` with a `get` and a `set`) over an explicit prop and emit says that clearly:

*components/PriceInput.vue*

```
<script setup lang="ts">
import { computed } from 'vue'

const props = defineProps<{ modelValue: number }>()
const emit = defineEmits<{ 'update:modelValue': [pence: number] }>()

const text = computed({
  get: () => (props.modelValue / 100).toFixed(2),
  set: (raw: string) => emit('update:modelValue', Math.round(Number(raw) * 100)),
})
</script>

<template>
  <input v-model.lazy="text" inputmode="decimal" />
</template>
```

`.lazy` makes `v-model` wait for the `change` event, which fires when the user leaves the field. Without it, every keystroke goes out through `set`, each new price comes straight back through `get`, and the field is reformatted while the user is still typing. Select the price and type `12.50` over it: the field reads `1.00` after the first key, and the parent ends up with `NaN`.

`defineModel()` takes `get` and `set` options too, as the modifiers section showed. When the field holds one type and the parent another, though, the long form keeps the two values apart and named: `props.modelValue` is the price in pence, `text` the string in the field.

**Check it stuck**3 questions from the Events and v-model quiz.

[**Practice now**](https://vuejs.school/learn/beginner/events-and-v-model?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Tutorial · 5 min read**Forms and inputs: v-model modifiers, checkboxes and selects**](https://vuejs.school/community/blog/forms-and-inputs-v-model-modifiers-checkboxes-and-selects) [Tutorial · 6 min read**Computed properties: cache, don't recalculate**](https://vuejs.school/community/blog/computed-properties-cache-dont-recalculate)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/v-model-on-your-own-components-with-definemodel#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-02-18","description":"Build a Stepper that works with v-model, first the long way with a prop and an emit, then in one line with defineModel(), plus named models and modifiers.","name":"v-model on your own components with defineModel()","url":"https://vuejs.school/community/blog/v-model-on-your-own-components-with-definemodel","breadcrumb":{"@id":"https://vuejs.school/community/blog/v-model-on-your-own-components-with-definemodel#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/v-model-on-your-own-components-with-definemodel"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/v-model-on-your-own-components-with-definemodel#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2025-02-18","description":"Build a Stepper that works with v-model, first the long way with a prop and an emit, then in one line with defineModel(), plus named models and modifiers.","headline":"v-model on your own components with defineModel()","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~di1tb2RlbCBvbiB5b3VyIG93biBjb21wb25lbnRzIHdpdGggZGVmaW5lTW9kZWwoKSDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_v-model%2Bon%2Byour%2Bown%2Bcomponents%2Bwith%2BdefineModel()%2Cdescription_~QnVpbGQgYSBTdGVwcGVyIHRoYXQgd29ya3Mgd2l0aCB2LW1vZGVsLCBmaXJzdCB0aGUgbG9uZyB3YXkgd2l0aCBhIHByb3AgYW5kIGFuIGVtaXQsIHRoZW4gaW4gb25lIGxpbmUgd2l0aCBkZWZpbmVNb2RlbCgpLCBwbHVzIG5hbWVkIG1vZGVscyBhbmQgbW9kaWZpZXJzLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy92LW1vZGVsLW9uLXlvdXItb3duLWNvbXBvbmVudHMtd2l0aC1kZWZpbmVtb2RlbCI%2Cs_Tgp2C1yaj0s4jyFo.png","@type":["Article","BlogPosting"],"articleSection":["Tutorial"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/v-model-on-your-own-components-with-definemodel#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/v-model-on-your-own-components-with-definemodel#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/v-model-on-your-own-components-with-definemodel#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"v-model on your own components with defineModel()","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~di1tb2RlbCBvbiB5b3VyIG93biBjb21wb25lbnRzIHdpdGggZGVmaW5lTW9kZWwoKSDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_v-model%2Bon%2Byour%2Bown%2Bcomponents%2Bwith%2BdefineModel()%2Cdescription_~QnVpbGQgYSBTdGVwcGVyIHRoYXQgd29ya3Mgd2l0aCB2LW1vZGVsLCBmaXJzdCB0aGUgbG9uZyB3YXkgd2l0aCBhIHByb3AgYW5kIGFuIGVtaXQsIHRoZW4gaW4gb25lIGxpbmUgd2l0aCBkZWZpbmVNb2RlbCgpLCBwbHVzIG5hbWVkIG1vZGVscyBhbmQgbW9kaWZpZXJzLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy92LW1vZGVsLW9uLXlvdXItb3duLWNvbXBvbmVudHMtd2l0aC1kZWZpbmVtb2RlbCI%2Cs_Tgp2C1yaj0s4jyFo.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~di1tb2RlbCBvbiB5b3VyIG93biBjb21wb25lbnRzIHdpdGggZGVmaW5lTW9kZWwoKSDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_v-model%2Bon%2Byour%2Bown%2Bcomponents%2Bwith%2BdefineModel()%2Cdescription_~QnVpbGQgYSBTdGVwcGVyIHRoYXQgd29ya3Mgd2l0aCB2LW1vZGVsLCBmaXJzdCB0aGUgbG9uZyB3YXkgd2l0aCBhIHByb3AgYW5kIGFuIGVtaXQsIHRoZW4gaW4gb25lIGxpbmUgd2l0aCBkZWZpbmVNb2RlbCgpLCBwbHVzIG5hbWVkIG1vZGVscyBhbmQgbW9kaWZpZXJzLg%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_mint%2Cp_Ii9jb21tdW5pdHkvYmxvZy92LW1vZGVsLW9uLXlvdXItb3duLWNvbXBvbmVudHMtd2l0aC1kZWZpbmVtb2RlbCI%2Cs_Tgp2C1yaj0s4jyFo.png"}]}
```