skip to content

In Vue 3.4+, what does defineModel() declare under the hood, and how does the ref it returns stay in sync with the parent?

level: middleimportance: must knowfreq 58%

answer

  1. a macro, not a runtime helper
  2. prop, modifiers prop, update event
  3. reads follow the prop
  4. writes emit first
  5. local mode without a parent binding

basics

~20 s

defineModel() is a script setup macro that declares a model prop, a matching modifiers prop and an update:<name> event, and returns a ref: reads follow the prop, writes emit the update and keep a local value only when no parent v-model is bound.

solid answer

~40 s

`defineModel()` is a compile-time macro available since Vue 3.4. The compiler adds a `modelValue` prop (or the name you pass), a `modelModifiers` prop (or `<name>Modifiers`) and an `update:modelValue` event to the component. At runtime it returns a special ref: **reading** it returns the latest prop value, **writing** it emits `update:modelValue`. When the parent has bound `v-model`, the ref does not change on its own; it waits for the parent to pass the new prop back, so the parent stays the source of truth. When the parent passed no `v-model`, the ref keeps the value locally so the component still works uncontrolled. The options object also takes prop options (`required`, `default`) and `get`/`set` transformers.

code

vue · 18 lines
vue
<!-- VolumeSlider.vue -->
<script setup lang="ts">
const volume = defineModel({ type: Number, required: true })

function louder() {
  // Emits update:modelValue; the displayed value changes when the parent stores it
  volume.value = volume.value + 10
}
</script>

<template>
  <input type="range" min="0" max="100" v-model.number="volume" />
  <button type="button" @click="louder">+10</button>
</template>

<!-- Parent clamps before storing:
  <VolumeSlider :modelValue="vol" @update:modelValue="v => (vol = Math.min(v, 80))" />
  the slider shows the clamped value because the prop is the source of truth -->

go deeper

for a junior

Recall that defineModel returns a ref you bind to an inner input, and that writing it updates the parent's v-model.

for a middle

Explain the generated prop, modifiers prop and update event, and the read-follows-prop, write-emits behaviour of the returned ref.

for a senior

Reason about the bound versus unbound modes, the default-value mismatch, and parents that clamp values before storing them.

for a principal

Decide whether library inputs should support uncontrolled use, and document which side owns the value in each mode.

## A macro that writes the boilerplate `defineModel()` exists only inside `<script setup>` and is compiled away, like `defineProps`. For `const model = defineModel()` the compiler generates three things on the component: 1. a **prop** named `modelValue` 2. a **modifiers prop** named `modelModifiers`, which carries any `v-model` modifiers the parent used 3. an **event** named `update:modelValue`, merged into the component's `emits` Passing a name, `defineModel('start')`, changes those to a `start` prop, a `startModifiers` prop and an `update:start` event. Declaring the same name twice is a compile error (`duplicate model name`). The options object doubles up: - **prop options** such as `type`, `required` and `default` go onto the generated prop - **`get` and `set`** are value transformers applied when the ref is read or written; they are stripped from the prop declaration ## The ref it returns At runtime the macro becomes a call that builds a **custom ref**. Its behaviour: | Operation | What happens | |---|---| | read `model.value` | returns the latest prop value, passed through `get` if given | | write `model.value = x` | emits `update:modelValue` with `x`, passed through `set` if given | | parent passes a new prop | the ref picks it up and triggers anything that read it | | write, parent bound `v-model` | local value is **not** changed; the ref waits for the new prop | | write, parent bound nothing | local value is updated so the component works on its own | The fourth row is the one to understand. When the parent binds `v-model`, the parent's variable is the **source of truth**. The child's write is a request; the ref only shows the new value once the parent assigns it and the prop comes back down. If the parent's handler clamps or rejects the value, the child shows whatever the parent actually stored. ## Uncontrolled use Vue decides the ref is **bound** only if the parent passed both the prop and its `onUpdate:` listener. If either is missing, a write updates the local value and still emits the event, so a `<Toggle>` used without `v-model` keeps working as a self-contained component. This is convenient but it is also where desynchronisation bugs come from: a parent that passes only `:modelValue` gets a child that drifts away from it. ## The default-value trap Because `default` goes onto a real prop, a `defineModel({ default: 1 })` child bound to a parent ref that is `undefined` shows `1` while the parent still holds `undefined`. The documentation warns about exactly this de-synchronisation. Initialise the parent's value instead of relying on the child's default when the parent needs to know the value. ## What it replaced Before 3.4, the idiomatic way to get a writable binding inside the child was a **writable computed** over the prop and the emit: - `get` returned `props.modelValue` - `set(v)` called `emit('update:modelValue', v)` That pattern still works and behaves like a bound `defineModel` ref: writes are requests and reads follow the prop. What it lacks is the local mode, so an unbound component built that way cannot hold a value on its own. `defineModel` also generates the prop, the modifiers prop and the event declaration, so the three cannot drift apart when someone renames one of them. | Concern | writable computed | `defineModel()` | |---|---|---| | declarations | written by hand | generated by the compiler | | works without a parent binding | no, shows the empty prop | yes, keeps a local value | | modifiers | read `props.modelModifiers` yourself | destructure the second tuple element | ## Why it is not just a ref - It never mutates the prop, so one-way data flow is preserved. - It still emits a real event, so a parent can listen with `@update:modelValue` without using `v-model`. - Destructuring it as `const [model, modifiers] = defineModel()` exposes the modifiers object alongside the ref. Knowing these mechanics lets you explain why the macro is safe to bind directly to an inner `<input v-model="model">`: every keystroke becomes an emitted event, and the input shows what the parent stored.

  • How does Vue decide whether a defineModel ref should update its local value on write?
    It checks the component's raw vnode props for both the model prop and its `onUpdate:` listener. If both are present the parent is bound, so the ref waits for the prop; if either is missing, it updates locally and still emits the event.
  • Why does defineModel({ default: 1 }) risk a mismatch with the parent?
    The default applies to the child's prop only. A parent binding a ref that is `undefined` keeps `undefined` while the child renders `1`, until the child first writes and emits. Initialise the parent's value instead.

A defineModel ref is like a hotel room thermostat wired to the building's central controller: turning the dial sends a request, and the display shows whatever the controller actually set. In a standalone room with no controller attached, the dial simply sets the room.

saying these in an interview costs you the question

  • defineModel returns a ref that mutates the parent's variable directly.
  • Writing the ref always changes the child's displayed value immediately, even when the parent rejects it.
  • defineModel can be called inside a composable outside script setup.
  • A defineModel component only works when the parent binds v-model.
  • defineModel does not declare an emitted event, so parents cannot listen for it.