You want a parent to write <Stepper v-model="qty" /> and have qty go up and down as someone clicks, the same way a ref follows what someone types into an <input v-model>. The Stepper here is a number with a minus button and a plus button. You will write it twice: by hand first, with a prop and an event, so you can see what v-model turns into, and then with defineModel(), the shorter form to use by default.
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:
<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:
<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:
<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:
<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:
<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 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:
<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:
<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:
<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>:
<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:
<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.