v-model on a Component

defineModel gives a component a two-way bound value in one line, and desugars to a prop plus an update event you can still write by hand.

Syntaxconst model = defineModel<string>({ default: '' })

v-model on your own component makes it behave like an input: the parent binds a value and the component can change it. The current way to accept that binding is defineModel, a compiler macro available only inside <script setup> and stable from Vue 3.4.

What it returns

defineModel() returns a ref. Read and write it through .value in script; the template unwraps it, so v-model="model" on an inner input is enough to wire the whole chain. Writing to it tells the parent to update.

What it desugars to

One line replaces a prop and an event. These describe the same component interface:

const model = defineModel<string>()

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

So v-model="query" in the parent is shorthand for :model-value="query" plus @update:model-value="query = $event".

More than one, and modifiers

Pass a name to get a second model: defineModel('firstName') is bound as v-model:first-name, and uses the prop firstName with the event update:firstName. Destructure the return value to read modifiers the parent attached, then transform the value in the set option.

What trips people up

Options are required, default, type, get and set. A default does not change the parent's value; it only fills in what the component reads until the parent sends something.

Example

Example · vue
<!-- src/components/SearchField.vue -->
<script setup lang="ts">
const model = defineModel<string>({ default: '' })

const { placeholder = 'Search' } = defineProps<{
  placeholder?: string
}>()

function clear() {
  // In script a ref is written through .value.
  model.value = ''
}
</script>

<template>
  <div>
    <!-- the template unwraps the ref, so no .value here -->
    <input v-model="model" type="search" :placeholder="placeholder" />
    <button type="button" :disabled="model.length === 0" @click="clear">
      Clear
    </button>
  </div>
</template>

<style scoped>
div {
  display: flex;
  gap: 0.5rem;
}
</style>

When to use it

  • Wrap a native input in a labelled field component that the parent still binds with a plain v-model.
  • Give a date-range component two bound values, v-model:from and v-model:to.
  • Let a parent opt into trimming with v-model.trim on your component rather than cleaning the value in every parent.
  • Read an older component that declares modelValue and update:modelValue by hand and recognise it as the same interface.

More examples

The longhand it replaces

The same component interface written out: the prop is named modelValue, the event update:modelValue, and a writable computed joins them so the inner input can use v-model. This is what every pre-3.4 codebase looks like, and it is worth recognising. Note that assigning to props.modelValue directly would be a read-only violation, which is why the computed emits instead.

Example · vue
<!-- src/components/SearchFieldLonghand.vue -->
<script setup lang="ts">
import { computed } from 'vue'

const props = defineProps<{ modelValue: string }>()

const emit = defineEmits<{
  'update:modelValue': [value: string]
}>()

// A writable computed turns the prop plus event into one value
// that v-model can bind to.
const model = computed({
  get: () => props.modelValue,
  set: (value: string) => emit('update:modelValue', value)
})
</script>

<template>
  <input v-model="model" type="search" />
</template>

Two models on one component

Two text inputs and a Swap button that exchanges them; both changes reach the parent. The parent writes <name-fields v-model:first-name="first" v-model:last-name="last" />. Each named model is its own prop and event pair, so firstName maps to the firstName prop and the update:firstName event. Use the kebab-case spelling in the template and the camelCase spelling in defineModel.

Example · vue
<!-- src/components/NameFields.vue -->
<script setup lang="ts">
const firstName = defineModel<string>('firstName', { default: '' })
const lastName = defineModel<string>('lastName', { default: '' })

function swap() {
  const held = firstName.value
  firstName.value = lastName.value
  lastName.value = held
}
</script>

<template>
  <fieldset>
    <label for="first">First name</label>
    <input id="first" v-model="firstName" type="text" />

    <label for="last">Last name</label>
    <input id="last" v-model="lastName" type="text" />

    <button type="button" @click="swap">Swap</button>
  </fieldset>
</template>

<style scoped>
fieldset {
  display: grid;
  gap: 0.5rem;
  border: none;
}
</style>

Modifiers on your own model

A text input whose value is cleaned on the way out. The parent writes <tag-input v-model.trim.uppercase="tag" /> and modifiers is { trim: true, uppercase: true }. Destructuring the return value is how you get at them; the second type parameter lists the modifier names you support, so an unknown one fails type checking. The set option runs on every write, before the value reaches the parent. For a named model, the destructure works the same way: const [firstName, firstNameModifiers] = defineModel('firstName').

Example · vue
<!-- src/components/TagInput.vue -->
<script setup lang="ts">
const [model, modifiers] = defineModel<string, 'trim' | 'uppercase'>({
  default: '',
  set(value) {
    let next = value
    if (modifiers.trim) {
      next = next.trim()
    }
    if (modifiers.uppercase) {
      next = next.toUpperCase()
    }
    return next
  }
})
</script>

<template>
  <input v-model="model" type="text" />
</template>

<style scoped>
input {
  font-family: monospace;
}
</style>

Discussion

  • Be the first to comment on this lesson.