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.
Inhaltsverzeichnis
- 1. Das Problem: v-model vor defineModel
- 2. Grundlegende Syntax von defineModel
- 3. Mehrere benannte Model-Bindings in einer Komponente
- 4. Modifiers bei v-model auswerten
- 5. Typisierung mit TypeScript
- 6. Default-Werte und required-Model
- 7. Lokale Mutation und wann sie beim Elternteil ankommt
- 8. Bestehende Komponenten migrieren
- 9. Häufige Fehler und Fallstricke
- 10. Zusammenfassung
- 11. FAQ
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
Bei benannten Models funktioniert die Typisierung genauso, nur dass der Name als erstes Argument und der Typparameter zusätzlich angegeben wird, also etwa defineModel
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