Formulare in einer Komponente
Kurz gesagt: Ein kontrolliertes Feld in React hält seinen Wert im Zustand (value und onChange), 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 value und onChange, 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).
const [name, setName] = useState('');
<input id="name" value={name} onChange={(e) => setName(e.target.value)} />value={name} ist die Richtung vom Zustand zum Feld. onChange ist der Weg zurück: e.target.value ist der Text des Feldes im Moment des Ereignisses. In React kommt onChange bei jeder Taste, nicht erst, wenn das Feld den Fokus verliert.
Die anderen Arten von Feldern sind gleich, nur mit einer anderen Eigenschaft:
<select value={payment} onChange={(e) => setPayment(e.target.value)}>…</select><input type="checkbox" checked={agree} onChange={(e) => setAgree(e.target.checked)} />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.
function CheckoutForm() { const [name, setName] = useState('');
return ( <form onSubmit={submit}> <label htmlFor="name">Name</label> <input id="name" value={name} onChange={(e) => setName(e.target.value)} /> </form> );}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.
- onChange fängt das Ereignis ab. e.target.value ist jetzt der Text des Feldes: „M“.
- setName("M") ändert den Zustand. Das ist der einzige Ort, an dem der Name lebt.
- Der Zustand hat sich geändert, also ruft React CheckoutForm erneut auf. Die Funktion gibt das Feld mit value = name zurück: „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 value und onChange. 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 value aus dem Zustand und onChange 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.jsximport CheckoutForm from './CheckoutForm.jsx';
export default function App() {
return (
<main>
<h1>Bestellung</h1>
<CheckoutForm />
</main>
);
}
CheckoutForm.jsximport { useState } from 'react';
const ITEMS = [
{ productId: 1, qty: 2 },
{ productId: 5, qty: 1 },
];
export default function CheckoutForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [address, setAddress] = useState('');
const [payment, setPayment] = useState('cash');
const [agree, setAgree] = useState(false);
const [orderId, setOrderId] = useState(null);
const [problem, setProblem] = useState('');
async function submit(e) {
e.preventDefault();
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, address, payment, items: ITEMS }),
});
const data = await res.json();
if (res.ok) setOrderId(data.id);
else setProblem(Object.values(data.errors).join(' '));
}
if (orderId) return <p role="status">Bestellung Nr. {orderId} ist angenommen.</p>;
return (
<form onSubmit={submit} noValidate>
<p>Im Warenkorb: Weißbrot × 2, Frischmilch × 1</p>
<label htmlFor="name">Name</label>
<input id="name" value={name} onChange={(e) => setName(e.target.value)} />
<label htmlFor="email">E-Mail</label>
<input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<label htmlFor="address">Adresse</label>
<input id="address" value={address} onChange={(e) => setAddress(e.target.value)} />
<label htmlFor="payment">Zahlung</label>
<select id="payment" value={payment} onChange={(e) => setPayment(e.target.value)}>
<option value="cash">Nachnahme</option>
<option value="card">Karte bei Lieferung</option>
</select>
<label>
<input type="checkbox" checked={agree} onChange={(e) => setAgree(e.target.checked)} />
Ich akzeptiere die Bedingungen
</label>
{problem && <p role="alert">{problem}</p>}
<button type="submit" disabled={!agree}>
Bestellen
</button>
</form>
);
}
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; }
Der Name bekommt value={name} und onChange={(e) => setName(e.target.value)}. Das Select bekommt value={payment} und ein onChange mit setPayment. Die Checkbox bekommt checked={agree} und onChange={(e) => setAgree(e.target.checked)}.
Die Prüfung erkennt value={name} ohne onChange: Das Feld wird schreibgeschützt, und die Konsole meldet You provided a `value` prop to a form field without an `onChange` handler. Sie erkennt auch setAgree(e.target.value): Der Wert der Checkbox ist bei jedem Klick „on“, also verlässt sie den Zustand nie wieder, wenn sie einmal angekreuzt ist.
Probier es aus
Ergänze die Lücke
Was gehört in die Lücke, damit die Checkbox gebunden ist?
const [agree, setAgree] = useState(false);
<input
type="checkbox"
checked={agree}
onChange={(e) => setAgree(e.target.)}
/>checked: Eine Checkbox sagt mit e.target.checked, ob sie angekreuzt ist, true oder false. e.target.value ist der mit dem Formular verschickte Text, und er ändert sich bei einem Klick nicht.Probier es aus
Finde den Fehler
Wähle die Zeile mit dem Fehler.
value={name} ohne onChange. Das Feld zeigt name, aber niemand ändert name, also setzt React es nach jeder Taste wieder auf leer. Es braucht auch onChange={(e) => setName(e.target.value)}.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" value={email} onChange={(e) => setEmail(e.target.value)} aria-invalid={!!errors.email} aria-describedby={errors.email ? 'email-error' : undefined}/>{errors.email && <p id="email-error" className="error">{errors.email}</p>}Das Formular hat novalidate (in JSX 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.jsximport CheckoutForm from './CheckoutForm.jsx';
export default function App() {
return (
<main>
<h1>Bestellung</h1>
<CheckoutForm />
</main>
);
}
CheckoutForm.jsximport { useState } from 'react';
const ITEMS = [
{ productId: 1, qty: 2 },
{ productId: 5, qty: 1 },
];
export default function CheckoutForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [address, setAddress] = useState('');
const [payment, setPayment] = useState('cash');
const [agree, setAgree] = useState(false);
const [orderId, setOrderId] = useState(null);
const [problem, setProblem] = useState('');
const [errors, setErrors] = useState({});
function check() {
const found = {};
if (!name.trim()) found.name = 'Gib deinen Namen ein';
if (!email.includes('@')) found.email = 'Die E-Mail muss ein @ enthalten';
if (!address.trim()) found.address = 'Gib eine Lieferadresse ein';
return found;
}
async function submit(e) {
e.preventDefault();
const found = check();
setErrors(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, email, address, payment, items: ITEMS }),
});
const data = await res.json();
if (res.ok) setOrderId(data.id);
else setProblem(Object.values(data.errors).join(' '));
}
if (orderId) return <p role="status">Bestellung Nr. {orderId} ist angenommen.</p>;
return (
<form onSubmit={submit} noValidate>
<p>Im Warenkorb: Weißbrot × 2, Frischmilch × 1</p>
<label htmlFor="name">Name</label>
<input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
aria-invalid={!!errors.name}
aria-describedby={errors.name ? 'name-error' : undefined}
/>
{errors.name && <p id="name-error" className="error">{errors.name}</p>}
<label htmlFor="email">E-Mail</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && <p id="email-error" className="error">{errors.email}</p>}
<label htmlFor="address">Adresse</label>
<input
id="address"
value={address}
onChange={(e) => setAddress(e.target.value)}
aria-invalid={!!errors.address}
aria-describedby={errors.address ? 'address-error' : undefined}
/>
{errors.address && <p id="address-error" className="error">{errors.address}</p>}
<label htmlFor="payment">Zahlung</label>
<select id="payment" value={payment} onChange={(e) => setPayment(e.target.value)}>
<option value="cash">Nachnahme</option>
<option value="card">Karte bei Lieferung</option>
</select>
<label>
<input type="checkbox" checked={agree} onChange={(e) => setAgree(e.target.checked)} />
Ich akzeptiere die Bedingungen
</label>
{problem && <p role="alert">{problem}</p>}
<button type="submit" disabled={!agree}>
Bestellen
</button>
</form>
);
}
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, setErrors] = useState({}) und eine Funktion check() hinzu, die ein Objekt mit den Fehlern zurückgibt. In submit zuerst const found = check(); setErrors(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 {errors.name && <p 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.
setSending(true);const res = await fetch('/api/orders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, email, address, payment, items: ITEMS }),});const data = await res.json();setSending(false);if (res.ok) setOrderId(data.id);else setErrors(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
Unkontrollierte Felder. Ein Feld ohne value hält den Text selbst. Der Wert wird beim Absenden geholt: aus FormData oder mit einem ref. defaultValue gibt nur den Starttext vor. Das ist weniger Code, aber der Zustand weiß nicht, was der Kunde tippt, bevor er absendet. Man kann also den Button nicht über das Feld deaktivieren oder schon beim Tippen prüfen.
Von unkontrolliert zu kontrolliert. Ist value am Anfang undefined (useState() ohne Startwert) und wird später zu Text, sagt React in der Konsole A component is changing an uncontrolled input to be controlled. Der Startwert eines Textfeldes ist '', nicht undefined.
Formulare mit action in React 19. <form action={fn}> ruft fn mit den FormData des Formulars auf und leert danach die unkontrollierten Felder. Dazu gibt es auch Hooks (useActionState, useFormStatus). Das ist ein anderer Stil, näher am Formular aus der HTML-Lektion. Kontrollierte Felder bleiben der direkteste Weg, wenn die Oberfläche reagieren soll, während der Kunde tippt.
Ein Objekt statt fünf Zustände. Du kannst auch const [form, setForm] = useState({ name: '', email: '', … }) mit einer einzigen onChange-Funktion haben, die e.target.name liest. Das neue Objekt ist dann eine Kopie: setForm({ ...form, [e.target.name]: e.target.value }).
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.jsximport CheckoutForm from './CheckoutForm.jsx';
export default function App() {
return (
<main>
<h1>Bestellung</h1>
<CheckoutForm />
</main>
);
}
CheckoutForm.jsximport { useState } from 'react';
const ITEMS = [
{ productId: 1, qty: 2 },
{ productId: 5, qty: 1 },
];
export default function CheckoutForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [address, setAddress] = useState('');
const [payment, setPayment] = useState('cash');
const [agree, setAgree] = useState(false);
const [orderId, setOrderId] = useState(null);
const [errors, setErrors] = useState({});
const [sending, setSending] = useState(false);
function check() {
const found = {};
if (!name.trim()) found.name = 'Gib deinen Namen ein';
if (!email.includes('@')) found.email = 'Die E-Mail muss ein @ enthalten';
if (!address.trim()) found.address = 'Gib eine Lieferadresse ein';
return found;
}
async function submit(e) {
e.preventDefault();
const found = check();
setErrors(found);
if (Object.keys(found).length > 0) return;
setSending(true);
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, address, payment, items: ITEMS }),
});
const data = await res.json();
setSending(false);
if (res.ok) setOrderId(data.id);
else setErrors(data.errors);
}
if (orderId) return <p role="status">Bestellung Nr. {orderId} ist angenommen.</p>;
return (
<form onSubmit={submit} noValidate>
<p>Im Warenkorb: Weißbrot × 2, Frischmilch × 1</p>
{errors.items && <p role="alert">{errors.items}</p>}
<label htmlFor="name">Name</label>
<input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
aria-invalid={!!errors.name}
aria-describedby={errors.name ? 'name-error' : undefined}
/>
{errors.name && <p id="name-error" className="error">{errors.name}</p>}
<label htmlFor="email">E-Mail</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && <p id="email-error" className="error">{errors.email}</p>}
<label htmlFor="address">Adresse</label>
<input
id="address"
value={address}
onChange={(e) => setAddress(e.target.value)}
aria-invalid={!!errors.address}
aria-describedby={errors.address ? 'address-error' : undefined}
/>
{errors.address && <p id="address-error" className="error">{errors.address}</p>}
<label htmlFor="payment">Zahlung</label>
<select id="payment" value={payment} onChange={(e) => setPayment(e.target.value)}>
<option value="cash">Nachnahme</option>
<option value="card">Karte bei Lieferung</option>
</select>
<label>
<input type="checkbox" checked={agree} onChange={(e) => setAgree(e.target.checked)} />
Ich akzeptiere die Bedingungen
</label>
{sending && <p role="status">Wird gesendet…</p>}
<button type="submit" disabled={!agree || sending}>
Bestellen
</button>
</form>
);
}
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, setSending] = useState(false) hinzu. Um fetch herum: setSending(true) davor und setSending(false) nach der Antwort. Der Button ist disabled={!agree || sending}, und {sending && <p role="status">Wird gesendet…</p>} steht darüber. Bei einem Fehler statt des gemeinsamen Absatzes setErrors(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