Before Vue 3.4, every custom input needed a modelValue prop, an update:modelValue emit and a computed getter/setter to glue them — and the most common bug was mutating the prop directly, which Vue warns about and which silently breaks when the parent passes a non-reactive value.

defineModel() collapses that into one line that returns a ref you can read and write. The Vue composition API form adapters topic covers the form-level composables; this page is about the leaf: an input component that participates in v-model, applies modifiers, exposes its error to assistive technology, and forwards attributes to the right element.


Context and prerequisites

What defineModel does under the hood:

  • Declares a modelValue prop and an update:modelValue emit (or title / update:title for a named model).
  • Returns a ref whose getter reads the prop and whose setter emits the update. Writing the ref never mutates the prop.
  • Keeps a local copy when the parent does not bind v-model, so the component still works uncontrolled.
  • Exposes modifiers (v-model.trim, custom ones like v-model.digits) through a second return value, with optional get/set transforms.

For forms, the important part is that the input component stays a thin, predictable wrapper: the parent owns the value; the component owns presentation, formatting and accessibility wiring.

Before and after defineModel Before Vue 3.4 a custom input declared a modelValue prop, an update:modelValue emit and a computed with get and set to bridge them, and a common bug was assigning to the prop. With defineModel, one call returns a writable ref that emits updates, works uncontrolled when the parent does not bind v-model, and exposes modifiers with get and set transforms. Concern Before 3.4 With defineModel declare binding props + emits + computed const model = defineModel() write the value emit("update:modelValue", v) model.value = v parent omits v-model no local state local copy kept modifiers modelModifiers prop by hand [model, modifiers] + get/set common bug mutating the prop not possible via the ref

The core pattern: a text field with modifiers, error wiring and attribute forwarding

<!-- TextField.vue -->
<script setup lang="ts">
import { computed, useId } from "vue";

defineOptions({ inheritAttrs: false });   // forward $attrs to the <input>, not the wrapper

const props = defineProps<{ label: string; error?: string; hint?: string; required?: boolean }>();

// v-model with custom modifiers: v-model.trim and v-model.digits
const [model, modifiers] = defineModel<string, "trim" | "digits">({
  default: "",
  set(value) {
    // Transform on the way OUT to the parent. Keep this cheap: it runs per keystroke.
    let v = value;
    if (modifiers.digits) v = v.replace(/\D+/g, "");
    return v;
  },
});

const id = useId();                        // Vue 3.5+: SSR-safe unique id
const errorId = computed(() => (props.error ? `${id}-error` : undefined));
const hintId = computed(() => (props.hint ? `${id}-hint` : undefined));
// Order matters: screen readers read the error before the hint.
const describedBy = computed(() => [errorId.value, hintId.value].filter(Boolean).join(" ") || undefined);

function onBlur(e: FocusEvent) {
  // .trim is applied on blur, not per keystroke, so users can type spaces mid-word.
  if (modifiers.trim) model.value = (e.target as HTMLInputElement).value.trim();
}
</script>

<template>
  <div class="field">
    <label :for="id">{{ label }}<span v-if="required" aria-hidden="true"> *</span></label>
    <p v-if="hint" :id="hintId" class="hint">{{ hint }}</p>
    <input
      :id="id"
      v-model="model"
      v-bind="$attrs"
      :required="required"
      :aria-invalid="error ? 'true' : undefined"
      :aria-describedby="describedBy"
      @blur="onBlur"
    />
    <p v-if="error" :id="errorId" class="error">{{ error }}</p>
  </div>
</template>
<!-- Parent -->
<TextField v-model.trim="form.name" label="Full name" required :error="errors.name" autocomplete="name" />
<TextField v-model.digits="form.phone" label="Phone" inputmode="tel" :error="errors.phone" />

Step-by-step walkthrough

  1. Declare the model with defineModel. Type it (defineModel<string>()) and give it a default so the uncontrolled case has a value.
  2. Read modifiers from the second tuple element. Built-in v-model.trim and .number apply automatically on native inputs inside your component only if you pass them through; for custom components, you implement them in set (or on blur for trim).
  3. Transform in set, not in a watcher. set runs when the component writes the ref, before the parent is notified — one update instead of two.
  4. Turn off inheritAttrs and bind $attrs to the input. Attributes such as autocomplete, inputmode, name and maxlength belong on the <input>, not the wrapper div. The token list is in autocomplete tokens for autofill-friendly forms.
  5. Generate ids with useId. It is stable between server and client render, which avoids hydration mismatches on for, id and aria-describedby.
  6. Wire the error with aria-invalid and aria-describedby. The component receives error as a prop; the parent’s composable decides when to show it, per touched vs dirty vs visited.
A keystroke through a defineModel input with a modifier The user types a hyphen into the phone input. The native input's v-model writes the raw text into the model ref. The model's set transform strips non-digits and emits update:modelValue with the cleaned value. The parent's form state updates, and the new modelValue flows back down as a prop, so the input displays the digits only. User TextField input defineModel ref Parent form state types "-" model.value = "0207-" set(): emit "0207" prop modelValue = "0207"

Designing the component’s public API

A custom input is used in dozens of places, so its props are an API worth designing once. Keep it to what varies between uses: label, hint, error, required, plus v-model and any named models. Everything else — autocomplete, inputmode, maxlength, name, placeholder, disabled — should arrive as ordinary attributes through $attrs, so the component never needs a new prop when a use case needs a new native attribute. Resist props like showError or validateOn: whether an error is visible, and when validation runs, are decisions for the form composable, and a component that makes them independently produces forms where fields disagree about timing.

The required prop deserves care. Setting the native required attribute gives screen-reader users the “required” state for free, but it also turns on native constraint validation unless the form has novalidate. Most custom forms want the announcement without the browser bubble, so pair the component with novalidate on the form, or use aria-required="true" when native validation is not wanted at all.


Failure modes and edge cases

1. Caret jumps with transforming modifiers

When set changes the value the user typed (stripping a hyphen), the parent sends back a different string and the browser moves the caret to the end. For anything beyond stripping trailing characters, preserve the selection explicitly — the technique in preserving caret position in masked inputs.

2. .number on a custom component

v-model.number on a custom component sets modifiers.number but does nothing unless you implement it. For numeric fields, prefer keeping raw text and parsing in the form composable, as in controlled number inputs and intermediate values.

3. Attributes landing on the wrapper

Without inheritAttrs: false, autocomplete="email" ends up on the div, where it does nothing, and browser autofill stops working for the field. Also check class and style: they are part of $attrs and will move to the input — bind them to the wrapper separately if needed.

4. Multiple models

A date-range component can expose defineModel("start") and defineModel("end"), bound with v-model:start and v-model:end. Each is independent; validate their relationship at the form level, not inside the component.

5. Objects as models

defineModel<Address>() returns a ref to the parent’s object. Mutating model.value.city = "x" mutates the parent’s object without emitting — it works by accident with reactive parents and fails with plain objects. Replace the whole object: model.value = { ...model.value, city: "x" }.

What the component owns versus the parent The input component owns the label, input and error markup, id generation, aria-invalid and aria-describedby wiring, attribute forwarding, and cheap per-keystroke formatting through modifiers. The parent form owns the value, validation rules and timing, when an error is shown, and submission. Input component Markup: label, input, hint, error. useId, aria-invalid, aria-describedby. $attrs forwarding; modifier transforms. Parent form The value (via v-model). Rules and timing; which error to show. Submission and server errors.

Verification checklist


Frequently Asked Questions

Does defineModel work with Vue 3.3?

It was experimental in 3.3 behind a compiler flag and became stable in 3.4. On earlier versions, use the prop plus emit pattern with a computed getter and setter; the component’s public API (v-model) is identical, so parents do not change when you upgrade.

Should validation run inside the input component?

Keep rules in the form composable so they can see other fields and so timing is consistent. The component can run trivial presentational checks (such as showing a character count) but should receive its error as a prop.

How do I focus the inner input from the parent?

Expose a focus method with defineExpose({ focus: () => inputRef.value?.focus() }). The parent calls it through a template ref, which is how an error summary moves focus to a custom field.


Related

← Vue Composition API Form Adapters