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
modelValueprop and anupdate:modelValueemit (ortitle/update:titlefor 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 likev-model.digits) through a second return value, with optionalget/settransforms.
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.
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
- Declare the model with
defineModel. Type it (defineModel<string>()) and give it a default so the uncontrolled case has a value. - Read modifiers from the second tuple element. Built-in
v-model.trimand.numberapply automatically on native inputs inside your component only if you pass them through; for custom components, you implement them inset(or on blur fortrim). - Transform in
set, not in a watcher.setruns when the component writes the ref, before the parent is notified — one update instead of two. - Turn off
inheritAttrsand bind$attrsto the input. Attributes such asautocomplete,inputmode,nameandmaxlengthbelong on the<input>, not the wrapperdiv. The token list is in autocomplete tokens for autofill-friendly forms. - Generate ids with
useId. It is stable between server and client render, which avoids hydration mismatches onfor,idandaria-describedby. - Wire the error with
aria-invalidandaria-describedby. The component receiveserroras a prop; the parent’s composable decides when to show it, per touched vs dirty vs visited.
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" }.
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.