A Vue 3.4+ `<QuantityStepper>` built with defineModel shows 1 while the parent's bound ref stays undefined, or drifts from it; what causes it and how do you fix it?
answer
- who owns the value
- a default on the child only
- prop without its listener
- bound versus local mode
basics
~20 sEither defineModel's default fills the child's prop while the parent's ref stays undefined, or the parent passes only :modelValue so the ref runs in local mode. Initialise the parent's value and bind the full v-model.
solid answer
~40 sTwo causes produce this. First, `defineModel({ default: 1 })` puts a default on the child's **prop**; if the parent binds a ref that is `undefined`, the child renders `1` and the parent keeps `undefined` until the child's first write emits a value. The docs warn about this de-synchronisation; fix it by initialising the parent's ref. Second, a parent that writes `:modelValue="qty"` without the `@update:modelValue` listener leaves the ref in **local mode**: Vue sees no full `v-model`, so writes update the child's own copy and emit into nothing, and the child drifts from `qty`. Fix it by binding `v-model` or both halves. A parent handler that clamps or rejects the value does not cause drift, because a bound ref always shows the prop.
code
vue · 18 lines<!-- QuantityStepper.vue -->
<script setup lang="ts">
const count = defineModel({ type: Number, default: 1 })
</script>
<template>
<button type="button" @click="count--">-</button>
<output>{{ count }}</output>
<button type="button" @click="count++">+</button>
</template>
<!-- Buggy parent:
const qty = ref<number>() // child shows 1, qty is undefined
<QuantityStepper :modelValue="qty" /> // no listener: local mode, drifts
Fixed parent:
const qty = ref(1)
<QuantityStepper v-model="qty" /> -->go deeper
Recall that a v-model parent must bind both the value and the update, and that the parent should initialise its own value.
Explain how a defineModel default applies only to the child's prop and why a prop without its listener switches the ref to local mode.
Diagnose the two causes from Devtools and the parent template, rule out clamping handlers, and cover the contract with an emitted-value test.
Decide which library components may run uncontrolled, and require explicit parent ownership for values like quantities and form state.
## The symptom A cart line uses a stepper component: - the child is `<QuantityStepper>`, implemented with `const count = defineModel({ type: Number, default: 1 })` - the parent holds `const qty = ref<number>()` and binds it to the stepper - the stepper shows `1`, but an order summary reading `qty` shows nothing, or after some clicks the two numbers disagree The component is working as designed; the bug is in who owns the value. ## How a defineModel ref decides what to show `defineModel()` returns a custom ref with two modes: | Mode | When Vue uses it | On write | |---|---|---| | **bound** | the parent passed the model prop **and** its `onUpdate:` listener | emits the update and waits for the new prop | | **local** | either the prop or the listener is missing | stores the value locally **and** emits the update | Reads always return the latest prop value when the prop changes, and in local mode also the locally stored value. ## Cause 1: a default on the child `default` is a **prop option**. When the parent binds a ref whose value is `undefined`, Vue applies the child's default to the child's prop only: 1. the child renders `1` 2. the parent's `qty` is still `undefined`, so the summary is empty 3. the first click emits `2`, the parent stores it, and from then on both agree The Vue documentation calls out exactly this de-synchronisation for `defineModel` defaults. The fix is to make the parent the source of truth: `const qty = ref(1)`. Keep defaults on the child for uncontrolled use, not to paper over an uninitialised parent. ## Cause 2: a one-way prop in local mode A parent that writes `<QuantityStepper :modelValue="qty" />`, perhaps after removing an `@update:modelValue` handler during a refactor, passes the prop but no listener. Vue treats the ref as **unbound**: - clicks update the child's local value and emit an event nobody hears - the child shows `3` while `qty` is still `1` - when the parent later changes `qty` itself, the new prop overwrites the local value and the child jumps The fix is to restore the binding: `v-model="qty"`, or both `:modelValue` and `@update:modelValue`. ## What does not cause drift Candidates often blame a parent handler that clamps the value, such as `@update:modelValue="v => (qty = Math.min(v, 10))"`. That is a **bound** ref, so the child never stores its own write; it shows whatever the parent stored. At the limit the prop does not change and the display stays at `10`, which is correct. ## Diagnosing in practice - Inspect the component in Vue Devtools and compare its `modelValue` prop with the parent's state. - Check the parent template for both halves of the binding, especially after a refactor to hand-written bindings. - Search for `default` on `defineModel` calls whose parents might pass `undefined`. - Add a component test that mounts the child with `modelValue` and an `onUpdate:modelValue` spy, and asserts the emitted values rather than the child's own display. ## Preventing it in a component library The same two causes recur across teams, so it pays to close them structurally: - **Required models for owned values.** `defineModel({ type: Number, required: true })` produces a development warning when the prop is missing, which catches a parent that forgot the binding entirely. - **No defaults on controlled values.** Reserve `default` for models that are genuinely optional and meaningful without a parent, such as a disclosure's open state. - **Lint or review hand-written bindings.** A template with `:modelValue` but no `@update:modelValue` on a component is almost always a mistake, and it is easy to spot in review once the team knows the pattern. - **Document the mode.** State in the component's documentation whether it supports uncontrolled use, so consumers know whether omitting `v-model` is intended. ## The design lesson The local mode exists so a component can work uncontrolled, which is useful for simple widgets. It also means a half-bound parent does not fail loudly. For components where the parent's copy matters, such as quantities, prices or form state, mark the model `required: true` so a missing prop warns in development, and initialise the parent's value explicitly.
- Why does a parent handler that clamps the value not make a Vue defineModel child drift?With both the prop and the `onUpdate:` listener present, the ref is bound: a write only emits and never stores locally. The child reads the prop, so it shows the clamped value the parent stored, even when the prop stays the same.
- How does required: true on defineModel help here?It makes the generated prop required, so Vue warns in development when a parent forgets to pass it. It does not catch a missing listener, so the parent template still needs review.
saying these in an interview costs you the question
- A defineModel default also initialises the parent's bound ref.
- A parent handler that clamps the value makes the child keep its own unclamped value.
- Passing only :modelValue is enough for defineModel to stay in sync.
- Vue throws an error when a model prop is passed without its update listener.
- The child should fix drift by watching the prop and copying it into a separate ref.