Formulare in einer Komponente
Kurz gesagt: v-model in Vue verbindet ein Formularfeld in beide Richtungen mit dem Zustand, und so ist der Zustand die einzige Quelle für das Prüfen und Senden des Formulars.
Problem
Eingetippt, aber leer
Der Kunde füllt die Bestellung aus und drückt auf „Bestellen“. Der Server antwortet, der Name sei erforderlich. Und das Feld „Name“ zeigt den Namen.
Konsole
Tipp einen Namen, eine E-Mail-Adresse und eine Adresse ein, akzeptiere die Bedingungen und drück auf „Bestellen“. Das Feld behält „Maria Ivanova“, aber die Anfrage trägt name: ''. Die E-Mail und die Adresse haben v-model, der Name nicht. Der Code schickt den Zustand, und der Zustand weiß nicht, was im Feld steht.
Die Idee
Eine Wahrheit: der Zustand
In der Lektion über das DOM hat das Formular den Text selbst gehalten, und der Code hat ihn beim Absenden gelesen. In einer Komponente gibt es einen zweiten Ort: den Zustand. Sind die beiden nicht verbunden, sagt jeder etwas anderes, wie im Beispiel.
Die Lösung: Das Feld hat keine eigene Wahrheit. Es zeigt den Wert aus dem Zustand und sagt dem Zustand bei jeder Taste den neuen Text. Ein solches Feld heißt kontrolliert (englisch: controlled input).
<script setup>const name = ref('');</script>
<template> <input id="name" v-model="name" /></template>v-model ist die Kurzform für beide Richtungen: :value="name" (vom Zustand zum Feld) und @input="name = $event.target.value" (der Weg zurück). Das Ereignis input kommt bei jeder Taste.
v-model kennt die Art des Feldes. An einem Select und an einer Checkbox schreibst du also dasselbe:
<select v-model="payment">…</select><input type="checkbox" v-model="agree" />An einer Checkbox ist agree true oder false, und an einem Select ist es der value der gewählten Option.
Jetzt ist der Zustand die einzige Quelle: Was in ihm steht, steht im Feld, und umgekehrt. Der Button lässt sich über den Zustand deaktivieren (!agree), und die Anfrage schickt genau das, was der Kunde sieht.
Sieh es dir an
Sieh dir den Kreis an
Ein Buchstabe geht den ganzen Weg: Feld, Ereignis, Zustand, neues Zeichnen und wieder das Feld. Danach dasselbe Feld ohne Bindung.
<script setup>const name = ref('');</script>
<template> <form @submit.prevent="submit"> <label for="name">Name</label> <input id="name" v-model="name" /> </form></template>Schritt für Schritt, in Worten
- Das Formular hält den Namen im Zustand: name = ''. Das Feld auf der Seite zeigt, was der Zustand sagt, und der sagt: leer.
- Der Kunde drückt M. Der Browser setzt den Buchstaben ins Feld und löst das Ereignis input aus.
- v-model hört auf input. Der Text des Feldes ist jetzt „M“.
- v-model schreibt ihn in das ref: name.value = "M". Das ist der einzige Ort, an dem der Name lebt.
- Das ref hat sich geändert, also führt Vue das Template erneut aus. v-model gibt dem Feld den Wert von name: „M“.
- Das Feld bekommt seinen Wert vom Zustand. Es ist derselbe Buchstabe, aber jetzt entscheidet der Zustand, was das Feld zeigt.
- Jede Taste läuft den ganzen Kreis: Feld, Ereignis, Zustand, neues Zeichnen, Feld. Eine einzige Quelle der Wahrheit: der Zustand.
- Jetzt dasselbe Feld ohne v-model. Das Feld funktioniert, aber niemand hört es ab und niemand gibt ihm einen Wert.
- Der Kunde tippt „Maria“. Die Buchstaben bleiben im DOM. Der Zustand sagt immer noch: name = ''.
- Beim Absenden schickt der Code den Zustand: { name: '' }. Der Server antwortet „Der Name ist erforderlich“, während das Feld „Maria“ zeigt.
- Die Regel: Jedes Feld hat v-model, einen Wert aus dem Zustand und ein Ereignis zurück dorthin. Dann können Feld und Zustand nicht auseinanderlaufen.
Der Kreis sieht wie ein unnötiger Umweg aus: Der Buchstabe steht auch ohne ihn im Feld. Aber seinetwegen können Zustand und Feld nicht auseinanderlaufen.
Probier es aus
Binde die Felder
Das Feld Name, das Select Zahlung und die Checkbox Ich akzeptiere die Bedingungen sind nicht an den Zustand gebunden. Binde alle drei. Der Button Bestellen ist deaktiviert, bis die Bedingungen akzeptiert sind. Die Prüfung tippt einen Namen, eine E-Mail-Adresse und eine Adresse ein, wählt Karte bei Lieferung, setzt und entfernt das Häkchen und bestellt dann. Sie erwartet Bestellung Nr. 61 ist angenommen. und eine Anfrage mit dem eingetippten Namen und payment: 'card'.
Konsole
Lösung
App.vue<script setup>
import CheckoutForm from './CheckoutForm.vue';
</script>
<template>
<main>
<h1>Bestellung</h1>
<CheckoutForm />
</main>
</template>
CheckoutForm.vue<script setup>
import { ref } from 'vue';
const ITEMS = [
{ productId: 1, qty: 2 },
{ productId: 5, qty: 1 },
];
const name = ref('');
const email = ref('');
const address = ref('');
const payment = ref('cash');
const agree = ref(false);
const orderId = ref(null);
const problem = ref('');
async function submit() {
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.value,
email: email.value,
address: address.value,
payment: payment.value,
items: ITEMS,
}),
});
const data = await res.json();
if (res.ok) orderId.value = data.id;
else problem.value = Object.values(data.errors).join(' ');
}
</script>
<template>
<p v-if="orderId" role="status">Bestellung Nr. {{ orderId }} ist angenommen.</p>
<form v-else novalidate @submit.prevent="submit">
<p>Im Warenkorb: Weißbrot × 2, Frischmilch × 1</p>
<label for="name">Name</label>
<input id="name" v-model="name" />
<label for="email">E-Mail</label>
<input id="email" type="email" v-model="email" />
<label for="address">Adresse</label>
<input id="address" v-model="address" />
<label for="payment">Zahlung</label>
<select id="payment" v-model="payment">
<option value="cash">Nachnahme</option>
<option value="card">Karte bei Lieferung</option>
</select>
<label>
<input type="checkbox" v-model="agree" />
Ich akzeptiere die Bedingungen
</label>
<p v-if="problem" role="alert">{{ problem }}</p>
<button type="submit" :disabled="!agree">
Bestellen
</button>
</form>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
form { display: grid; gap: 4px; max-width: 360px; }
label { font-weight: 600; margin-top: 8px; }
label:has(input[type="checkbox"]) { font-weight: 400; }
input, select, button { font: inherit; padding: 4px 6px; }
input[aria-invalid="true"] { outline: 2px solid #b3261e; }
.error { color: #b3261e; margin: 0; font-size: 0.9em; }
button { margin-top: 12px; justify-self: start; }
Alle drei Felder bekommen v-model: v-model="name", v-model="payment" am <select> und v-model="agree" an der Checkbox.
Die Prüfung erkennt :value="name" ohne v-model: Die Buchstaben erscheinen, aber name bleibt leer und geht leer raus. Sie erkennt auch :checked="agree": Das Kästchen sieht angekreuzt aus, aber agree bleibt false, und der Button wird nicht aktiviert.
Probier es aus
Ergänze die Lücke
Was gehört in die Lücke, damit die Checkbox gebunden ist?
<script setup>
const agree = ref(false);
</script>
<template>
<input type="checkbox" ="agree" />
</template>v-model: An einer Checkbox bindet es checked und das Ereignis change, und agree bekommt true oder false.Probier es aus
Finde den Fehler
Wähle die Zeile mit dem Fehler.
:value="name" ist nur die Richtung vom Zustand zum Feld. Die Buchstaben bleiben im DOM, name bleibt leer, und beim nächsten Zeichnen setzt Vue das Feld wieder darauf zurück. Es braucht v-model="name".Die Idee
Eine Prüfung neben dem Feld
Der Server prüft jede Bestellung. Aber der Kunde soll nicht auf eine Antwort warten müssen, um zu erfahren, dass er die Adresse vergessen hat. Also prüft auch das Formular selbst, bevor es sendet.
- Wann. Beim Drücken auf „Bestellen“. Gibt es Fehler, geht keine Anfrage raus.
- Was. Eine Funktion
check()gibt ein Objekt mit den Fehlern je Feld zurück:{ name: 'Gib deinen Namen ein', email: … }. Ein leeres Objekt heißt „alles in Ordnung“. Es liegt im Zustanderrors. - Wo. Neben dem Feld, nicht in einer gemeinsamen Liste oben. Der Kunde sieht, welches Feld er korrigieren muss.
- Für alle. Ein Screenreader sieht nicht, dass der Text rot ist. Deshalb bekommt das Feld
aria-invalid="true"(der Reader sagt „ungültig“), und die Meldung bekommt eineid, auf die das Feld mitaria-describedbyzeigt. Kommt der Fokus ins Feld, liest der Reader auch die Meldung vor.
<input id="email" v-model="email" :aria-invalid="!!errors.email" :aria-describedby="errors.email ? 'email-error' : undefined"/><p v-if="errors.email" id="email-error" class="error">{{ errors.email }}</p>Das Formular hat novalidate. Ohne das stoppt der Browser das Absenden mit seinen eigenen Sprechblasen, bevor dein Code drankommt, und die beiden Prüfungen kommen sich in die Quere.
Der Server prüft noch einmal. Die Prüfung im Browser ist eine Bequemlichkeit für den Kunden. Sie schützt den Shop nicht: Der Code der Seite liegt beim Kunden, und eine Anfrage kann auch ohne ihn kommen. Warum das so ist, erklärt die Lektion „Nicht vertrauenswürdige Eingaben“.
Probier es aus
Prüfen vor dem Senden
Füge eine Prüfung vor dem Senden hinzu. Ein leerer Name ergibt Gib deinen Namen ein, eine E-Mail ohne @ ergibt Die E-Mail muss ein @ enthalten, eine leere Adresse ergibt Gib eine Lieferadresse ein. Jede Meldung steht neben ihrem Feld, in einem Element mit der id name-error, email-error und address-error, und das Feld hat aria-invalid=true und aria-describedby, das darauf zeigt. Bei Fehlern geht keine Anfrage raus. Die Prüfung akzeptiert die Bedingungen, bestellt mit einem leeren Formular, dann mit einer E-Mail ohne @ und zuletzt mit allem richtig.
Konsole
Lösung
App.vue<script setup>
import CheckoutForm from './CheckoutForm.vue';
</script>
<template>
<main>
<h1>Bestellung</h1>
<CheckoutForm />
</main>
</template>
CheckoutForm.vue<script setup>
import { ref } from 'vue';
const ITEMS = [
{ productId: 1, qty: 2 },
{ productId: 5, qty: 1 },
];
const name = ref('');
const email = ref('');
const address = ref('');
const payment = ref('cash');
const agree = ref(false);
const orderId = ref(null);
const problem = ref('');
const errors = ref({});
function check() {
const found = {};
if (!name.value.trim()) found.name = 'Gib deinen Namen ein';
if (!email.value.includes('@')) found.email = 'Die E-Mail muss ein @ enthalten';
if (!address.value.trim()) found.address = 'Gib eine Lieferadresse ein';
return found;
}
async function submit() {
const found = check();
errors.value = found;
if (Object.keys(found).length > 0) return;
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.value,
email: email.value,
address: address.value,
payment: payment.value,
items: ITEMS,
}),
});
const data = await res.json();
if (res.ok) orderId.value = data.id;
else problem.value = Object.values(data.errors).join(' ');
}
</script>
<template>
<p v-if="orderId" role="status">Bestellung Nr. {{ orderId }} ist angenommen.</p>
<form v-else novalidate @submit.prevent="submit">
<p>Im Warenkorb: Weißbrot × 2, Frischmilch × 1</p>
<label for="name">Name</label>
<input
id="name"
v-model="name"
:aria-invalid="!!errors.name"
:aria-describedby="errors.name ? 'name-error' : undefined"
/>
<p v-if="errors.name" id="name-error" class="error">{{ errors.name }}</p>
<label for="email">E-Mail</label>
<input
id="email"
type="email"
v-model="email"
:aria-invalid="!!errors.email"
:aria-describedby="errors.email ? 'email-error' : undefined"
/>
<p v-if="errors.email" id="email-error" class="error">{{ errors.email }}</p>
<label for="address">Adresse</label>
<input
id="address"
v-model="address"
:aria-invalid="!!errors.address"
:aria-describedby="errors.address ? 'address-error' : undefined"
/>
<p v-if="errors.address" id="address-error" class="error">{{ errors.address }}</p>
<label for="payment">Zahlung</label>
<select id="payment" v-model="payment">
<option value="cash">Nachnahme</option>
<option value="card">Karte bei Lieferung</option>
</select>
<label>
<input type="checkbox" v-model="agree" />
Ich akzeptiere die Bedingungen
</label>
<p v-if="problem" role="alert">{{ problem }}</p>
<button type="submit" :disabled="!agree">
Bestellen
</button>
</form>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
form { display: grid; gap: 4px; max-width: 360px; }
label { font-weight: 600; margin-top: 8px; }
label:has(input[type="checkbox"]) { font-weight: 400; }
input, select, button { font: inherit; padding: 4px 6px; }
input[aria-invalid="true"] { outline: 2px solid #b3261e; }
.error { color: #b3261e; margin: 0; font-size: 0.9em; }
button { margin-top: 12px; justify-self: start; }
Füge const errors = ref({}) und eine Funktion check() hinzu, die ein Objekt mit den Fehlern zurückgibt. In submit zuerst const found = check(); errors.value = found;, und wenn found Schlüssel hat, ein return vor fetch. Neben jedem Feld: :aria-invalid="!!errors.name", :aria-describedby="errors.name ? 'name-error' : undefined" und <p v-if="errors.name" id="name-error">…</p>.
Die Prüfung erkennt eine Meldung ohne aria-invalid und aria-describedby: Auf dem Bildschirm sieht sie gleich aus, aber für einen Screenreader ist das Feld in Ordnung. Sie erkennt auch Meldungen, nach denen die Anfrage trotzdem rausgeht: Dann erscheint kurz darauf auch die Antwort des Servers.
Die Idee
Senden und die Antwort des Servers
Die Anfrage braucht Zeit, und der Kunde weiß nicht, ob sein Klick angekommen ist. Klickt er noch einmal, geht eine zweite Bestellung raus. Deshalb hat der Zustand auch sending:
- Vor
fetchwirdsendingzutrue. Der Button istdisabled, und daneben steht „Wird gesendet…“. - Nach der Antwort wird
sendingzufalse, egal was zurückkam. - 201: Der Body ist
{ id }. Das Formular wird durch „Bestellung Nr. 61 ist angenommen.“ ersetzt. - 400: Der Body ist
{ errors: { name, email, address, items } }, eine Meldung je Feld. Sie kommen in dasselbeerrorsund erscheinen so an denselben Stellen neben den Feldern.itemshat kein Feld: Es erscheint neben der Zeile mit dem Warenkorb.
sending.value = true;const res = await fetch('/api/orders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: name.value, email: email.value, /* … */ items: ITEMS }),});const data = await res.json();sending.value = false;if (res.ok) orderId.value = data.id;else errors.value = data.errors;Die Prüfung im Browser ist einfacher als die des Servers: Sie sucht nur nach „@“. Die E-Mail maria@shop besteht sie, und der Server schickt sie mit „Die E-Mail-Adresse ist ungültig“ zurück. Das Formular muss also auch einen Fehler zeigen können, den es nicht kannte.
Genauer, für Neugierige
Modifikatoren von v-model. v-model.trim entfernt die Leerzeichen am Anfang und am Ende, v-model.number macht aus dem Text eine Zahl (für type="number"), und v-model.lazy aktualisiert bei change (wenn das Feld den Fokus verliert), nicht bei jeder Taste.
:value ohne Ereignis. Das Feld zeigt name, die Buchstaben gehen ins DOM, aber name ändert sich nicht. Wird die Komponente aus einem anderen Grund erneut gezeichnet (etwa weil eine Fehlermeldung erscheint), setzt Vue das Feld auf den Wert von name zurück, und das Getippte verschwindet.
Ein Objekt statt fünf refs. Du kannst auch const form = reactive({ name: '', email: '', … }) mit v-model="form.name" haben. Vue beobachtet die Eigenschaften des Objekts, also funktioniert v-model an jeder von ihnen.
v-model an einer Komponente ist :modelValue nach unten und @update:modelValue nach oben, wie in der Lektion über das Hochreichen des Zustands. So entstehen Feld-Komponenten (<MoneyInput v-model="price" />).
Wann prüfen. Beim Absenden ist am einfachsten. Eine Prüfung bei jeder Taste zeigt Fehler, bevor der Kunde fertig ist („die E-Mail ist ungültig“ nach dem ersten Buchstaben). Die übliche Antwort: die erste Prüfung beim Absenden oder beim Verlassen des Feldes (blur), danach bei jeder Taste, damit der Fehler verschwindet, sobald er behoben ist.
Fokus auf einen Fehler. Nach einem fehlgeschlagenen Absenden hilft es, den Fokus auf das erste Feld mit einem Fehler zu setzen. Dann kommen Tastatur und Screenreader direkt zum Problem.
Die Prüfung sieht den Code nicht. Die Aufgaben hier prüfen das Verhalten: was angezeigt wird, was deaktiviert ist, was gesendet wird. Sie sehen nicht, ob die Prüfung in einer eigenen Funktion steht oder wie die Variablen heißen.
Übung
Die Bestellung, bis zum Ende
Jetzt prüft das Formular im Browser, aber es zeigt nicht, dass es sendet, und es legt die Fehler des Servers in einen einzigen Absatz. Mach daraus eine echte Bestellung: Während die Anfrage läuft, ist der Button Bestellen deaktiviert, und es gibt einen Text Wird gesendet… (mit role=status). Bei einer Antwort 400 kommen die Fehler aus data.errors neben die Felder, an dieselben Stellen wie deine (email-error und aria-invalid an der E-Mail), und der Fehler für items neben die Zeile mit dem Warenkorb. Die Prüfung tippt die E-Mail maria@shop ein: Sie besteht deine Prüfung, aber der Server schickt sie mit Die E-Mail-Adresse ist ungültig zurück. Dann hängt sie .bg an, und die Bestellung geht durch: Bestellung Nr. 61 ist angenommen.
Konsole
Lösung
App.vue<script setup>
import CheckoutForm from './CheckoutForm.vue';
</script>
<template>
<main>
<h1>Bestellung</h1>
<CheckoutForm />
</main>
</template>
CheckoutForm.vue<script setup>
import { ref } from 'vue';
const ITEMS = [
{ productId: 1, qty: 2 },
{ productId: 5, qty: 1 },
];
const name = ref('');
const email = ref('');
const address = ref('');
const payment = ref('cash');
const agree = ref(false);
const orderId = ref(null);
const errors = ref({});
const sending = ref(false);
function check() {
const found = {};
if (!name.value.trim()) found.name = 'Gib deinen Namen ein';
if (!email.value.includes('@')) found.email = 'Die E-Mail muss ein @ enthalten';
if (!address.value.trim()) found.address = 'Gib eine Lieferadresse ein';
return found;
}
async function submit() {
const found = check();
errors.value = found;
if (Object.keys(found).length > 0) return;
sending.value = true;
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.value,
email: email.value,
address: address.value,
payment: payment.value,
items: ITEMS,
}),
});
const data = await res.json();
sending.value = false;
if (res.ok) orderId.value = data.id;
else errors.value = data.errors;
}
</script>
<template>
<p v-if="orderId" role="status">Bestellung Nr. {{ orderId }} ist angenommen.</p>
<form v-else novalidate @submit.prevent="submit">
<p>Im Warenkorb: Weißbrot × 2, Frischmilch × 1</p>
<p v-if="errors.items" role="alert">{{ errors.items }}</p>
<label for="name">Name</label>
<input
id="name"
v-model="name"
:aria-invalid="!!errors.name"
:aria-describedby="errors.name ? 'name-error' : undefined"
/>
<p v-if="errors.name" id="name-error" class="error">{{ errors.name }}</p>
<label for="email">E-Mail</label>
<input
id="email"
type="email"
v-model="email"
:aria-invalid="!!errors.email"
:aria-describedby="errors.email ? 'email-error' : undefined"
/>
<p v-if="errors.email" id="email-error" class="error">{{ errors.email }}</p>
<label for="address">Adresse</label>
<input
id="address"
v-model="address"
:aria-invalid="!!errors.address"
:aria-describedby="errors.address ? 'address-error' : undefined"
/>
<p v-if="errors.address" id="address-error" class="error">{{ errors.address }}</p>
<label for="payment">Zahlung</label>
<select id="payment" v-model="payment">
<option value="cash">Nachnahme</option>
<option value="card">Karte bei Lieferung</option>
</select>
<label>
<input type="checkbox" v-model="agree" />
Ich akzeptiere die Bedingungen
</label>
<p v-if="sending" role="status">Wird gesendet…</p>
<button type="submit" :disabled="!agree || sending">
Bestellen
</button>
</form>
</template>
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
form { display: grid; gap: 4px; max-width: 360px; }
label { font-weight: 600; margin-top: 8px; }
label:has(input[type="checkbox"]) { font-weight: 400; }
input, select, button { font: inherit; padding: 4px 6px; }
input[aria-invalid="true"] { outline: 2px solid #b3261e; }
.error { color: #b3261e; margin: 0; font-size: 0.9em; }
button { margin-top: 12px; justify-self: start; }
Füge const sending = ref(false) hinzu. Um fetch herum: sending.value = true davor und sending.value = false nach der Antwort. Der Button ist :disabled="!agree || sending", und <p v-if="sending" role="status">Wird gesendet…</p> steht darüber. Bei einem Fehler statt des gemeinsamen Absatzes errors.value = data.errors: Die Meldungen des Servers kommen in dasselbe errors und erscheinen neben den Feldern. Den Absatz problem braucht es nicht mehr.
Die Prüfung erkennt einen Button, der aktiviert bleibt, obwohl „Wird gesendet…“ dasteht, und einen Button, der nach der Antwort nie wieder aktiviert wird (ein vergessenes false).
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Die erste Komponente · Bedingungen: leer, Fehler, lädt · Zustand · Daten von einer API in einer Komponente
- Geht weiter in: Gemeinsamer Zustand · Das Schaufenster des Shops
- Aufgaben zum Thema: Effekte, Daten und Formulare