Vue 3.4+ defineModel im Detail: Zwei-Wege-Bindung ohne Boilerplate
AI generated
{ }
Vue 3.4+ · Composition API
defineModel im Detail
Zwei-Wege-Bindung ohne props- und emit-Boilerplate

Seit Vue 3.4 (und weiter verfeinert in Vue 3.5) übernimmt das Compiler-Makro defineModel die gesamte Verdrahtung, die früher aus einer props-Deklaration, einem emits-Eintrag und einem manuellen emit('update:modelValue', wert) bestand. Wer v-model an eigenen Komponenten anbietet, kommt mit einer einzigen Zeile aus, die sich wie ein normaler ref verhält, aber automatisch in beide Richtungen synchronisiert.

14 Min. Lesezeit defineModel TypeScript

1. Das Problem: v-model vor defineModel

Bevor es defineModel gab, musste jede Komponente, die v-model unterstützen sollte, drei separate Bausteine sauber aufeinander abstimmen: eine props-Deklaration für modelValue, einen passenden emits-Eintrag für update:modelValue und im Template oder Script eine Stelle, an der genau dieses Event manuell ausgelöst wurde. Das war kein kompliziertes Muster, aber es war repetitiv, und bei mehreren gleichzeitigen v-model-Bindungen an einer Komponente wuchs der Boilerplate schnell auf ein Vielfaches an, weil jede benannte Bindung ihr eigenes Props-Emit-Paar brauchte.

Besonders ärgerlich war, dass man im Template nie direkt auf den Wert schreiben konnte. Stattdessen musste man entweder eine computed-Property mit explizitem Getter und Setter bauen, die intern den emit aufruft, oder bei jeder Änderung von Hand emit('update:modelValue', neuerWert) schreiben. Beides funktionierte zuverlässig, verteilte die Logik aber über mehrere Stellen der Komponente und machte es für neue Teammitglieder schwerer nachzuvollziehen, wo ein Wert tatsächlich herkommt und wohin er beim Schreiben wandert.


<!-- Vor Vue 3.4: klassisches v-model-Muster -->
<script setup lang="ts">
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{
  (e: 'update:modelValue', value: string): void
}>()

function onInput(event: Event) {
  const value = (event.target as HTMLInputElement).value
  emit('update:modelValue', value)
}
</script>

<template>
  <input :value="props.modelValue" @input="onInput" />
</template>

2. Grundlegende Syntax von defineModel

Mit defineModel reduziert sich dasselbe Muster auf einen einzigen Aufruf, der einen ref zurückgibt. Dieser ref liest beim Lesen den aktuellen Wert der Prop und löst beim Schreiben automatisch das passende update-Event aus, ganz ohne dass man selbst emit aufrufen muss. Im Template kann dieser ref direkt mit v-model an ein natives Input-Element gebunden werden, so als wäre es ein lokaler, komponenteninterner Zustand, obwohl er in Wirklichkeit vom Elternelement kommt und dorthin auch wieder zurückfließt.

Intern registriert der Compiler dabei weiterhin eine modelValue-Prop und ein update:modelValue-Event, das Verhalten unter der Haube hat sich also nicht grundlegend geändert. Was sich geändert hat, ist die Oberfläche, mit der man als Entwickler arbeitet: statt zwei getrennte Konzepte (Prop lesen, Event schreiben) im Kopf zu behalten, gibt es nur noch ein einziges reaktives Objekt, das sich in beide Richtungen wie ein normaler ref verhält und entsprechend leicht zu testen und weiterzureichen ist.


<!-- Ab Vue 3.4: defineModel -->
<script setup lang="ts">
const model = defineModel<string>()
</script>

<template>
  <input v-model="model" />
</template>

3. Mehrere benannte Model-Bindings in einer Komponente

Sobald eine Komponente mehr als einen Wert nach außen synchronisieren soll, etwa ein Formularfeld, das gleichzeitig einen Titel und einen Aktiv-Status verwaltet, kommt die benannte Variante von defineModel ins Spiel. Statt defineModel() ohne Argument ruft man defineModel('title') und defineModel('active') auf, und der Elternteil bindet dann mit v-model:title und v-model:active jeweils die passende Prop. Jede dieser Bindungen erzeugt intern wieder ein eigenes Props-Emit-Paar, aber im Code der Kindkomponente ist davon nichts mehr zu sehen.

Der große Vorteil gegenüber dem alten Muster zeigt sich hier besonders deutlich, weil man früher für jede zusätzliche benannte Bindung eine weitere Props-Deklaration, einen weiteren emits-Eintrag und eine weitere manuelle emit-Aufruf-Stelle brauchte. Mit defineModel bleibt der Aufwand pro zusätzlichem Model auf eine einzige Zeile begrenzt, und die Namen der Models sind an genau der Stelle sichtbar, wo sie auch benutzt werden, was die Komponente insgesamt leichter lesbar macht.

4. Modifiers bei v-model auswerten

Vue kennt bei v-model eingebaute Modifiers wie .trim, .number oder .lazy, die auf nativen Elementen automatisch angewendet werden. Bei selbstgebauten Komponenten mit defineModel kann man eigene Modifiers definieren und im Script darauf reagieren, indem man das zweite Element aus dem Rückgabewert von defineModel destrukturiert. Dieses zweite Element ist ein Objekt, dessen Schlüssel den vom Elternteil gesetzten Modifiers entsprechen, sodass man etwa prüfen kann, ob capitalize gesetzt wurde, und den Wert entsprechend vor dem Schreiben transformieren kann.

Diese Möglichkeit wird in der Praxis seltener gebraucht als die einfache Zwei-Wege-Bindung, ist aber genau dann nützlich, wenn eine wiederverwendbare Komponente in verschiedenen Kontexten leicht unterschiedliches Verhalten haben soll, ohne dass man dafür zusätzliche Props einführen muss. Ein Formularfeld, das je nach Einsatzort Großbuchstaben erzwingen oder Leerzeichen am Rand entfernen soll, kann diese Logik so direkt am v-model-Aufruf im Elternteil konfigurieren, statt eine weitere explizite Prop dafür zu vergeben.

5. Typisierung mit TypeScript

defineModel unterstützt wie defineProps generische Typparameter, sodass man den Typ des Models direkt am Makro angeben kann, etwa defineModel() oder defineModel(). Der Compiler leitet daraus sowohl den Typ der intern registrierten Prop als auch den Typ des zurückgegebenen ref korrekt ab, ohne dass man Prop und Event getrennt typisieren müsste, wie es beim alten Muster mit defineProps und defineEmits nötig war.

Bei benannten Models funktioniert die Typisierung genauso, nur dass der Name als erstes Argument und der Typparameter zusätzlich angegeben wird, also etwa defineModel('title'). In Kombination mit Optionen wie required oder default lässt sich so ein vollständig typsicheres Vertragsmodell zwischen Eltern- und Kindkomponente aufbauen, bei dem der TypeScript-Compiler sowohl im Template als auch im Script bereits zur Entwicklungszeit warnt, wenn ein falscher Typ übergeben oder erwartet wird.

6. Default-Werte und required-Model

Genau wie bei defineProps kann man defineModel ein Optionsobjekt mitgeben, in dem sich default und required setzen lassen. Ein Model mit default verhält sich so, dass die Komponente auch dann sinnvoll funktioniert, wenn der Elternteil kein v-model bindet, was besonders bei wiederverwendbaren UI-Bausteinen praktisch ist, die sowohl kontrolliert als auch unkontrolliert eingesetzt werden können sollen.

Setzt man stattdessen required auf true, macht man deutlich, dass die Komponente ohne gebundenes v-model keinen sinnvollen Sinn ergibt, und TypeScript kann diese Erwartung beim Einsatz der Komponente überprüfen. In der Praxis empfiehlt es sich, bei Formularfeldern und ähnlichen Steuerelementen eher required zu verwenden, während bei optionalen Zusatzfunktionen wie einem ein- und ausklappbaren Panel ein sinnvoller default oft die bessere Wahl ist, weil die Komponente dann auch ohne explizite Bindung ein nachvollziehbares Verhalten zeigt.

7. Lokale Mutation und wann sie beim Elternteil ankommt

Ein häufiges Missverständnis besteht darin, dass eine direkte Zuweisung an den von defineModel zurückgegebenen ref sofort synchron beim Elternteil ankommt. Tatsächlich funktioniert das genauso wie bei jeder anderen reaktiven Prop-Kopplung in Vue: die Zuweisung löst intern das update-Event aus, und Vue verarbeitet dieses Event im gleichen Tick, sodass der neue Wert im nächsten Render-Zyklus sowohl in der Kind- als auch in der Elternkomponente konsistent ist.

Wichtig ist dabei, dass die lokale Variable in der Kindkomponente nicht unabhängig vom Elternteil existiert, sondern tatsächlich an dessen Zustand gekoppelt bleibt, solange der Elternteil v-model bindet. Bindet der Elternteil dagegen keinen ref, sondern einen festen Literalwert an die Prop, verhält sich das Model wie eine normale, nicht synchronisierte Prop, und Änderungen in der Kindkomponente wirken sich dann nur lokal aus, was man beim Debuggen von scheinbar nicht ankommenden Änderungen im Hinterkopf behalten sollte.

8. Bestehende Komponenten migrieren

Die Migration einer bestehenden Komponente vom alten Muster zu defineModel ist in den meisten Fällen eine reine Löschung von Code, nicht ein Umbau. Man entfernt die entsprechende Prop aus defineProps, den passenden Eintrag aus defineEmits sowie alle Stellen, an denen manuell emit('update:modelValue', ...) aufgerufen wurde, und ersetzt all das durch eine einzige defineModel-Zeile. Im Template wird jede Stelle, an der bisher props.modelValue gelesen wurde, durch model.value ersetzt, und jede manuelle emit-Aufruf-Stelle durch eine direkte Zuweisung an model.value.

Ein Punkt, auf den man bei der Migration achten sollte, ist, dass defineModel mindestens Vue 3.4 voraussetzt und der Compiler das Makro entsprechend übersetzen können muss. In Projekten, die noch auf einer älteren Vue-Version oder einem älteren Build-Tooling laufen, funktioniert defineModel nicht, ohne dass zuvor ein Versions-Upgrade durchgeführt wird. Es lohnt sich daher, die Migration schrittweise Komponente für Komponente durchzuführen und nach jedem Schritt die betroffenen Formulare manuell durchzuklicken, um sicherzustellen, dass sich das Verhalten nicht verändert hat.

9. Häufige Fehler und Fallstricke

Ein verbreiteter Fehler ist, defineModel innerhalb einer Funktion oder eines watch-Callbacks erneut aufzurufen, in der Annahme, man könne so einen zusätzlichen, dynamisch benannten Model erzeugen. defineModel ist wie defineProps und defineEmits ein Compiler-Makro und darf nur auf oberster Ebene von script setup stehen, niemals bedingt oder innerhalb einer Schleife, weil der Compiler den Aufruf statisch analysieren muss, um die passenden Props und Events zu registrieren.

Ein zweiter häufiger Stolperstein ist, den zurückgegebenen ref versehentlich direkt im Template als reinen Lesewert zu behandeln und Änderungen weiterhin über einen manuellen emit vorzunehmen, quasi als Mischung aus altem und neuem Muster. Das funktioniert zwar technisch, weil beide Wege letztlich dasselbe Event auslösen, macht den Code aber inkonsistent und verschenkt genau den Lesbarkeitsgewinn, den defineModel eigentlich bringen soll. Wer defineModel einsetzt, sollte konsequent nur noch über den zurückgegebenen ref lesen und schreiben und keine manuellen emit-Aufrufe für dasselbe Event mehr im Code belassen.

Aspekt Vor defineModel (Vue < 3.4) Mit defineModel (Vue >= 3.4) Bemerkung
Props-Deklaration props: { modelValue: String } const model = defineModel() Keine separate Props-Deklaration mehr nötig
Event-Emission emit('update:modelValue', wert) model.value = wert Verhält sich wie ein normaler ref
Benannte Bindungen Eigenes Props/Emit-Paar je Name defineModel('title') Beliebig viele benannte Models möglich
TypeScript defineProps<{ modelValue: string }>() defineModel() Generische Typisierung direkt am Makro
Lesbarkeit im Template props.modelValue lesen, emit zum Schreiben model.value lesen und schreiben Ein reaktives Objekt statt zwei getrennter Konzepte

Mironsoft

Vue-Architektur, Composition API und Nuxt-Performance

Vue-Anwendungen, die mit jedem Feature nicht komplizierter werden?

Wir prüfen bestehende Vue- und Nuxt-Projekte auf unstrukturierte Composables, ungenutzte Reaktivität und aufgeblähte Bundles und bauen daraus eine Architektur, die neue Features aufnimmt, ohne die Codebasis unübersichtlicher zu machen.

Architektur-Review

Composables, State-Management und Komponentenstruktur auf Wartbarkeit prüfen.

Performance-Audit

Reaktivitäts-Overhead, Bundle-Größe und Nuxt-Rendering-Strategie systematisch optimieren.

Nuxt-Integration

SSR/SSG-Setup und API-Anbindung robust und typsicher aufbauen.

10. Zusammenfassung

Vue defineModel: Das Wichtigste auf einen Blick

Seit wann verfügbar

Stabil seit Vue 3.4, weiter verfeinert in Vue 3.5

Kernvorteil

Kein manuelles props- und emit-Paar mehr für v-model

Mehrere Bindings

Beliebig viele benannte Models über defineModel('name')

TypeScript

Generische Typisierung direkt am Makro möglich

11. FAQ: Vue defineModel: Das Wichtigste auf einen Blick

1Ab welcher Vue-Version ist defineModel verfügbar?
defineModel ist seit Vue 3.4 stabil verfügbar und wurde in Vue 3.5 in Detailfragen wie der Typinferenz weiter verfeinert. Für Projekte auf älteren Vue-3-Versionen ist zunächst ein Upgrade auf mindestens 3.4 nötig.
2Muss ich emits weiterhin manuell deklarieren, wenn ich defineModel nutze?
Nein. defineModel registriert die passende Prop und das passende update-Event automatisch im Hintergrund, eine zusätzliche manuelle Deklaration über defineEmits ist für dieses Event nicht mehr nötig.
3Kann ich mehrere v-model-Bindungen an einer Komponente haben?
Ja. Dafür ruft man defineModel mit einem Namen als Argument mehrfach auf, etwa defineModel('title') und defineModel('active'), und der Elternteil bindet dann mit v-model:title beziehungsweise v-model:active.
4Wie verhält sich defineModel ohne gebundenes v-model im Elternteil?
Ist kein v-model gebunden, verhält sich das Model wie eine gewöhnliche Prop mit optionalem Default-Wert. Schreibzugriffe auf den ref lösen dann zwar weiterhin das Event aus, es hört aber niemand zu, was für isoliert nutzbare Komponenten ein sinnvolles Fallback-Verhalten ist.
5Kann ich den Typ eines Models mit TypeScript einschränken?
Ja, über einen generischen Typparameter wie defineModel() oder defineModel(). Der Compiler leitet daraus sowohl den Prop-Typ als auch den Typ des zurückgegebenen ref korrekt ab.
6Was bewirkt required bei defineModel?
required markiert das Model als zwingend zu bindende Prop, ähnlich wie bei defineProps. TypeScript und die Laufzeitwarnung von Vue weisen dann darauf hin, wenn die Komponente ohne passendes v-model verwendet wird.
7Wie lese ich Modifiers wie .trim bei einem eigenen Model aus?
Man destrukturiert den zweiten Rückgabewert von defineModel, ein Objekt mit den vom Elternteil gesetzten Modifiers als Schlüssel, und kann darauf im Script reagieren, um den Wert vor dem Schreiben entsprechend zu transformieren.
8Ist die Migration alter v-model-Komponenten aufwendig?
In der Regel nicht. Es handelt sich meist um eine Löschung von Boilerplate: props- und emits-Eintrag sowie manuelle emit-Aufrufe entfallen, defineModel ersetzt alles in einer Zeile, und im Template wird props.modelValue durch model.value ersetzt.
9Darf ich defineModel innerhalb einer Funktion aufrufen?
Nein. defineModel ist wie defineProps ein Compiler-Makro und muss auf oberster Ebene von script setup stehen, nicht bedingt und nicht innerhalb einer Schleife oder Funktion, da der Compiler den Aufruf statisch auswerten muss.
10Lohnt sich defineModel auch für sehr einfache Komponenten?
Ja, gerade dort zahlt sich der Aufwandsunterschied besonders aus, weil ein einzeiliges defineModel den kompletten früheren Drei-Teile-Boilerplate ersetzt und der Code dadurch spürbar kompakter und leichter lesbar wird.