skip to content

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?

level: seniorimportance: should knowfreq 40%

answer

  1. who owns the value
  2. a default on the child only
  3. prop without its listener
  4. bound versus local mode

basics

~20 s

Either 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 s

Two 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
vue
<!-- 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

for a junior

Recall that a v-model parent must bind both the value and the update, and that the parent should initialise its own value.

for a middle

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.

for a senior

Diagnose the two causes from Devtools and the parent template, rule out clamping handlers, and cover the contract with an emitted-value test.

for a principal

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.