Manuell erstellte React-Formulare: Kontrollierte Eingabefelder, Validierung und Absendezustände
Erfahren Sie, wie React-Formulare funktionieren – von kontrollierten Eingaben und typspezifischen Handlern über Validierung, Absendezustände, dynamische Felder und Uploads bis hin zu den Vorteilen einer Formularbibliothek.
Quellen: React Forms: A Complete Guide from Basics to Production Patterns
Anmelden, Bezahlen, Suchen, Profileinstellungen, Umfragen – nahezu jede App basiert auf Formularen. In React bedeutet ein Formular die Verwaltung von Werten, Validierung, Fehlermeldungen, Absenden, Lade- und Serverfehlern, Feldern, die zur Laufzeit hinzugefügt werden, Zurücksetzungen sowie Leistung. Diese Anleitung zeigt Schritt für Schritt dieses Konzept, von kontrollierten Eingaben bis hin zu einem vollständigen Registrierungsformular ohne Bibliotheken, und erläutert, wann React Hook Form und Zod sinnvoll sind.
Wie ein Formular in einer React-App funktioniert
Ein Formular sammelt Informationen vom Benutzer, wie auf diesem Registrierungsbildschirm:
┌──────────────────────────────┐
│ Create Account │
│ │
│ Name [______________] │
│ Email [______________] │
│ Password [______________] │
│ │
│ [ Register ] │
└──────────────────────────────┘
React verbindet diese Eingabefelder mit der Anwendungslogik, und fast jedes Formular folgt dem gleichen Lebenszyklus:
User Input
↓
Form State
↓
Validation
↓
Submit
↓
API Request
↓
Success / Error
Kontrollierte Komponenten: Der Zustand als Quelle der Wahrheit
In einem kontrollierten Komponenten stammt der value des Eingabefeldes aus dem React-State, und jede Änderung wird über onChange wieder zurückgeschrieben.
import { useState } from "react";
function LoginForm() {
const [email, setEmail] = useState("");
return (
<input
value={email}
onChange={(event) => {
setEmail(event.target.value);
}}
/>
);
}
Durch das Setzen von value={email} entscheidet React, was im Feld angezeigt wird. Jeder Tastendruck durchläuft diesen Zyklus:
User types
↓
onChange fires
↓
setEmail()
↓
React state updates
↓
Component renders
↓
Input receives new value
Da der angezeigte Wert stets aus dem State abgeleitet wird, ist der State die einzige Quelle der Wahrheit.
Der Name „kontrolliert“ spiegelt wider, dass das Eingabefeld keinen eigenen Wert besitzt:
<input
value={email}
onChange={handleChange}
/>
Daten fließen vom State zum Element, über die Eingabe des Benutzers und den Handler, und wieder zurück in den State:
React State
│
▼
input value
│
▼
User types
│
▼
onChange
│
▼
React State
Ein State-Objekt für viele Felder
Ein useState-Aufruf pro Feld führt zu schlechter Skalierbarkeit:
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
Ein einzelnes Objekt, das nach dem Feldnamen keygt wird, ist einfacher zu warten. Diese Form speichert drei Felder in einem Objekt, teilt sich eine handleChange-Funktion und protokolliert die Daten bei Absenden.
import { useState } from "react";
function RegisterForm() {
const [form, setForm] = useState({
name: "",
email: "",
password: "",
});
const handleChange = (event) => {
const { name, value } = event.target;
setForm((previous) => ({
...previous,
[name]: value,
}));
};
const handleSubmit = (event) => {
event.preventDefault();
console.log(form);
};
return (
<form onSubmit={handleSubmit}>
<input
name="name"
value={form.name}
onChange={handleChange}
/>
<input
name="email"
value={form.email}
onChange={handleChange}
/>
<input
name="password"
type="password"
value={form.password}
onChange={handleChange}
/>
<button type="submit">
Register
</button>
</form>
);
}
Die Schlüsselzeile ist die berechnete Eigenschaft [name]: value. Das name-Attribut jedes Eingabefelds entspricht einem Zust Schlüssel, sodass eine Handler-Funktion das jeweilige Feld aktualisiert, das ausgelöst wurde.
Für Texteingabefelder liest die Handler-Funktion den Inhalt des Elements aus dem Ereignis:
<input
value={name}
onChange={(event) => {
setName(event.target.value);
}}
/>
event.target ist das Eingabefeld, daher ist event.target.value sein aktueller Text:
User types: Salim
event.target.value
↓
"Salim"
Korrekte Handhabung jeder Eingabetyp
Formulare enthalten Text-, Passwort-, Zahl-, Checkbox-, Radio-, Select-, Textarea-, Datei-, Datum- und Bereichskontrollen. Die meisten folgen demselben Muster, mit einigen wichtigen Unterschieden.
Text, Passwort und Zahl
Ein Textfeld verknüpft value mit seiner Schlüsselwerte:
<input
type="text"
value={form.name}
onChange={handleChange}
/>
Ein Passwortfeld unterscheidet sich nur durch den type:
<input
type="password"
value={form.password}
onChange={handleChange}
/>
Ebenso verhält es sich mit einem Zahleneingabefeld:
<input
type="number"
value={form.age}
onChange={handleChange}
/>
Das Problem: event.target.value ist selbst bei type="number" eine Zeichenkette. Der untenstehende Auszug mischt Code und Erklärung, doch es geht darum, dass man "25" statt 25 erhält und Number() zur Umwandlung benötigt.
const age = event.target.value;
may produce:
"25" rather than: 25
If you need a number:
const age = Number(event.target.value);
Achten Sie auf leere Werte: Number("") gibt 0 zurück, weshalb viele Formulare die rohe Zeichenkette beibehalten und erst bei Validierung oder Absenden umwandeln.
Checkboxen verwenden checked
Für eine Checkbox verknüpfen Sie checked mit einem Booleschen Wert und lesen Sie event.target.checked:
<input
type="checkbox"
checked={form.agree}
onChange={(event) => {
setForm((previous) => ({
...previous,
agree: event.target.checked,
}));
}}
/>
event.target.value bei einem Kontrollkästchen gibt das Wertattribut zurück (standardmäßig "on"), nicht an, ob es angekreuzt ist.
Select-, Textarea- und Radio-Gruppen
Ein select-Element wird über das value-Attribut gesteuert; eine leere erste Option dient als Platzhalter.
<select
name="role"
value={form.role}
onChange={handleChange}
>
<option value="">
Select role
</option>
<option value="frontend">
Frontend Developer
</option>
<option value="backend">
Backend Developer
</option>
</select>
React behandelt textarea wie ein Eingabefeld, wobei stattdessen ein value-Attribut verwendet wird anstelle des Textinhalts:
<textarea
name="message"
value={form.message}
onChange={handleChange}
/>
Radio-Buttons in einer Gruppe teilen sich einen name. Jeder wird angekreuzt, wenn sein value mit dem aktuellen Zustand übereinstimmt, und der gemeinsame Handler speichert die Auswahl in form.gender.
<label>
<input
type="radio"
name="gender"
value="male"
checked={form.gender === "male"}
onChange={handleChange}
/>
Male
</label>
<label>
<input
type="radio"
name="gender"
value="female"
checked={form.gender === "female"}
onChange={handleChange}
/>
Female
</label>
Übernahme der Abgabe
Standardmäßig sendet ein Browser das Formular selbst ab und kann anschließend weiter navigieren oder die Seite neu laden. React-Apps handhaben den Absenden in JavaScript, weshalb der Absendehandler mit event.preventDefault() beginnt, wie im Registrierungsbeispiel. Danach liegt der weitere Ablauf in Ihren Händen:
Submit
↓
preventDefault()
↓
Validate
↓
API
↓
Update UI
Validierung vor der Anfrage
Durch die Validierung wird überprüft, ob die Eingaben zulässig sind. Eine erste Version sammelt Fehler, die nach Feldern geordnet werden:
const validate = () => {
const errors = {};
if (!form.name.trim()) {
errors.name = "Name is required";
}
if (!form.email.trim()) {
errors.email = "Email is required";
}
if (!form.password) {
errors.password = "Password is required";
}
return errors;
};
Der Absendehandler führt zunächst eine Validierung durch, speichert Fehler und beendet den Vorgang frühzeitig, sobald Fehler vorhanden sind:
const handleSubmit = (event) => {
event.preventDefault();
const validationErrors = validate();
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
// Submit form
};
Die Fehler werden in einem eigenen Zustand gespeichert:
const [errors, setErrors] = useState({});
Zu einem bestimmten Zeitpunkt kann dieser Zustand Folgendes enthalten:
{
email: "Invalid email",
password: "Password must contain at least 8 characters"
}
Jedes Feld zeigt seine Meldung bedingungsabhängig an:
{errors.email && (
<p>{errors.email}</p>
)}
Eine bessere validate-Funktion nimmt das Formular als Argument, wodurch sie rein und testbar wird, und überprüft Format sowie Länge, sobald ein Feld vorhanden ist:
function validate(form) {
const errors = {};
if (!form.name.trim()) {
errors.name = "Name is required";
}
if (!form.email.trim()) {
errors.email = "Email is required";
} else if (!form.email.includes("@")) {
errors.email = "Invalid email";
}
if (!form.password) {
errors.password = "Password is required";
} else if (form.password.length < 8) {
errors.password =
"Password must contain at least 8 characters";
}
return errors;
}
Der Handler ruft es auf dieselbe Weise auf:
const errors = validate(form);
if (Object.keys(errors).length > 0) {
setErrors(errors);
return;
}
Der Test includes("@") erfasst nur offensichtliche Tippfehler; der eigentliche Beweis dafür, dass eine Adresse funktioniert, ist eine Bestätigungs-E-Mail.
Kunden- und Servervalidierung erfüllen unterschiedliche Aufgaben
Die Client-Seitige Validierung läuft im Browser und umfasst in der Regel:
- Pflichtfelder
- E-Mail-Format
- Mindestlänge des Passworts
- Passwortbestätigung
Sie liefert schnelle Rückmeldungen, eine bessere Benutzererfahrung und weniger unnötige Anfragen, stellt aber keine Sicherheitsbarriere dar. Der Server muss die Überprüfungen wiederholen und Fragen beantworten, die der Browser nicht beantworten kann:
- Ist die E-Mail bereits registriert?
- Ist der Benutzer autorisiert?
- Ist der Preis gültig?
- Darf dieser Benutzer diese Aktion ausführen?
Sichere Anwendungen nutzen beide Ansätze. Schreiben Sie niemals eine solche Logik:
if (clientValidationPassed) {
// Data is safe
}
Browser-Prüfungen können umgangen werden, indem die Seite bearbeitet oder die API direkt aufgerufen wird, weshalb der Backend-Teil jede Anfrage unabhängig validiert:
Request
↓
Authentication
↓
Authorization
↓
Validation
↓
Business Logic
↓
Database
Modellierung der Einreichungsstatus
Ein Produktivformular durchläuft verschiedene Zustände, die von der Benutzeroberfläche widergespiegelt werden sollten:
Idle
↓
Submitting
↓
Success
or
Idle
↓
Submitting
↓
Error
Zwei Zustandsvariablen überwachen diese Zustände:
const [isSubmitting, setIsSubmitting] = useState(false);
const [serverError, setServerError] = useState("");
Der asynchrone Handler setzt das Flagge, löscht alte Serverfehler, wartet auf die Anfrage, protokolliert bei Fehlern eine freundliche Nachricht und setzt das Flagge in jedem Fall im finally-Blöck zurück.
const handleSubmit = async (event) => {
event.preventDefault();
setIsSubmitting(true);
setServerError("");
try {
await createUser(form);
} catch (error) {
setServerError("Unable to create account");
} finally {
setIsSubmitting(false);
}
};
Das Deaktivieren und Umschreiben des Buttons während der Anfrage verhindert doppelte Einreichungen:
<button
type="submit"
disabled={isSubmitting}
>
{isSubmitting
? "Creating..."
: "Create Account"}
</button>
Der Lebenszyklus des Buttons spiegelt den Ablauf der Anfrage wider:
User clicks Submit
↓
Request starts
↓
Button disabled
↓
Request finishes
↓
Button enabled
Kontrollierte versus unkontrollierte Eingaben
Bei kontrollierten Eingaben speichert der React-State den Wert. Der folgende Block zeigt die Kurzform, ein vollständiges Komponentenbeispiel sowie den Datenfluss, getrennt durch Zeilen aus Sternchen.
<input
value={email}
onChange={handleChange}
/>
********************************************
function Form() {
const [email, setEmail] = useState("");
return (
<input
value={email}
onChange={(event) =>
setEmail(event.target.value)
}
/>
);
}
********************************************
DOM event
↓
React state
↓
Render
↓
DOM value
Bei unkontrollierten Eingaben behält das DOM den Wert bei, und React liest ihn über einen Ref ab, in der Regel beim Absenden, ohne jede Tastenbetätigung zu speichern:
const emailRef = useRef();
<input ref={emailRef} />
********************************************
function Form() {
const emailRef = useRef(null);
const handleSubmit = (event) => {
event.preventDefault();
console.log(emailRef.current.value);
};
return (
<form onSubmit={handleSubmit}>
<input ref={emailRef} />
<button type="submit">
Submit
</button>
</form>
);
}
********************************************
DOM
↓
Input value
Wählen Sie kontrollierte Eingaben für sofortige Validierung, bedingte Benutzeroberflächen, abgeleitete Werte, Formatierung, vom React gesteuerten State oder benutzerdefinierte Interaktionen. Da React den Wert kennt, kann die Benutzeroberfläche sofort reagieren:
{password.length < 8 && (
<p>Password is too short</p>
)}
Wählen Sie unkontrollierte Eingaben für einfache Formulare, wenn der Zustand nach jeder Tasteneingabe nichts bringt, wenn Sie weniger Aktualisierungen möchten oder wenn eine Bibliothek die Referenzen für Sie verwaltet. Die Form-Aktionen in React 19 basieren ebenfalls auf diesem Modell; siehe asynchrone Formulare mit useActionState und useOptimistic.
Wann eine Formularbibliothek sinnvoll ist
React Hook Form
Das Manuelle Verbinden von Dutzenden Feldern ist mühsam. React Hook Forms register verbindet ein Eingabefeld und definiert dessen Regeln, handleSubmit prüft die Eingaben vor dem Aufruf Ihrer Callback-Funktion und formState.errors gibt die Fehlermeldungen an.
import { useForm } from "react-hook-form";
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm();
const onSubmit = (data) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register("email", {
required: "Email is required",
})}
/>
{errors.email && (
<p>{errors.email.message}</p>
)}
<input
type="password"
{...register("password", {
required: "Password is required",
minLength: {
value: 8,
message:
"Password must be at least 8 characters",
},
})}
/>
{errors.password && (
<p>{errors.password.message}</p>
)}
<button type="submit">
Login
</button>
</form>
);
}
Es vereinfacht die Registrierung, Validierung, Fehlerbehandlung, Übermittlung, Zurücksetzung, dynamische Felder sowie den Formzustand. Da es standardmäßig Eingabefelder ohne Kontrolle registriert, wird verhindert, dass die gesamte Form bei jeder Tastenbetätigung neu gerendert wird – im Gegensatz zu naiven, kontrollierten Formen.
Schema-Validierung mit Zod
Je mehr Regeln hinzukommen, desto weiter entfernen sich die verstreuten if-Anweisungen voneinander. Zod beschreibt die erwartete Datenstruktur in einer einzigen Deklaration:
import { z } from "zod";
const userSchema = z.object({
name: z
.string()
.min(2, "Name must be at least 2 characters"),
email: z
.string()
.email("Invalid email"),
password: z
.string()
.min(8, "Password must be at least 8 characters"),
});
Eine gängige Kombination in der Produktion verwendet die Bibliothek für den Zustand und das Schema für die Regeln:
React Hook Form
+
Zod
↓
Form state + validation
Das Schema kann sehr kompakt sein:
const formSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
React Hook Form integriert ein Schema über einen Resolver – das eignet sich für große Formulare, bei denen die Regeln konsistent bleiben müssen.
Dynamische, bedingte und abhängige Felder
Wiederkehrende Gruppen
Benutzer fügen oft nach Bedarf Einträge hinzu, wie beispielsweise Bildungsdaten:
Education
Degree [________]
College [________]
[ + Add Education ]
Speichern Sie sie als Array von Objekten:
const [education, setEducation] = useState([
{
degree: "",
college: "",
},
]);
Das Hinzufügen einer Zeile fügt ein leeres Objekt hinzu:
setEducation((previous) => [
...previous,
{
degree: "",
college: "",
},
]);
Die Darstellung erfolgt durch Durchlaufen des Arrays:
{education.map((item, index) => (
<div key={index}>
<input
value={item.degree}
onChange={(event) => {
// update degree
}}
/>
<input
value={item.college}
onChange={(event) => {
// update college
}}
/>
</div>
))}
Ein Index key ist nur sicher, solange Zeilen nie entfernt oder umgeordnet werden; ansonsten sollten stabile IDs verwendet werden, da sonst typisierte Texte der falschen Zeile zugeordnet werden können.
Bedingte Felder
Einige Felder hängen von einer früheren Auswahl ab, wie beispielsweise die Firmendaten für ein Geschäftskonto:
Account Type
○ Individual
○ Business
If the user chooses:
Business
show:
Company Name
GST Number
Company Address
Eine bedingte Ausdruckslogik kümmert sich darum:
{form.accountType === "business" && (
<>
<input
name="companyName"
value={form.companyName}
onChange={handleChange}
/>
<input
name="gstNumber"
value={form.gstNumber}
onChange={handleChange}
/>
</>
)}
Falls der Benutzer zurückwechselt, sollten die versteckten Werte gelöscht oder ausgeschlossen werden, damit veraltete Daten nicht übermittelt werden.
Kaskadierte Auswahlmöglichkeiten
Land, Bundesland und Stadt bilden eine Kette:
Country
↓
State
↓
City
Änderungen weiter oben setzen alles darunter zurück, bevor neue Optionen abgerufen werden:
Country changes
↓
Clear State
↓
Fetch states
↓
User selects State
↓
Fetch cities
Da die Optionen vom Server stammen, passt TanStack Query gut zur Formularbibliothek für das Caching sowie die Anzeige von Ladezuständen.
Datei hochladen
Dateieingaben lassen sich nicht steuern; lesen Sie die Datei aus event.target.files ein:
<input
type="file"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) {
console.log(file);
}
}}
/>
Zum Hochladen senden Sie einen FormData-Körper:
const formData = new FormData();
formData.append("name", form.name);
formData.append("file", file);
await fetch("/api/upload", {
method: "POST",
body: formData,
});
Setzen Sie Content-Type: multipart/form-data nicht selbst mit fetch und FormData; der Browser generiert diesen Header zusammen mit dem erforderlichen Grenzwert.
Große Formulare schnell halten
Große, komplexe Formulare belasten ein einfaches, kontrolliertes Formular, das bei jeder Tasteneingabe neu gerendert wird:
User types
↓
setState
↓
Render
↓
Large form tree
Praktische Gegenmaßnahmen:
- Bewahren Sie den Zustand lokal auf, anstatt ihn ohne Grund global zu speichern.
- Teilen Sie das Formular in Abschnitte auf, damit bei jeder Änderung weniger neu gerendert wird:
Form
├── PersonalDetails
├── Address
├── Education
├── Experience
└── Documents
- Nutzen Sie eine geeignete Bibliothek wie React Hook Form.
- Vermeiden Sie aufwendige Berechnungen oder API-Aufrufe pro Zeichen.
- Debouncen Sie das, was ausgeführt werden muss, wie zum Beispiel die Überprüfung der Benutzernamenverfügbarkeit:
Username input
↓
Wait 300ms
↓
Check availability
Ein vollständiges Formular ohne Bibliothek
Das endgültige Beispiel kombiniert eine Konstante initialForm für Zurücksetzungen, getrennte Zustände für Feldfehler, Absendefehler und Serverfehler, eine validate-Funktion sowie einen asynchronen Absendevorgang, der JSON sendet, eine nicht-OK-Antwort als Fehler behandelt, bei Erfolg zurücksetzt und stets das Ladeflag entfernt. Beschriftungen verknüpfen sich über htmlFor und id mit den Eingabefeldern.
import { useState } from "react";
const initialForm = {
name: "",
email: "",
password: "",
};
function RegisterForm() {
const [form, setForm] = useState(initialForm);
const [errors, setErrors] = useState({});
const [isSubmitting, setIsSubmitting] =
useState(false);
const [serverError, setServerError] =
useState("");
const handleChange = (event) => {
const { name, value } = event.target;
setForm((previous) => ({
...previous,
[name]: value,
}));
};
const validate = () => {
const errors = {};
if (!form.name.trim()) {
errors.name = "Name is required";
}
if (!form.email.includes("@")) {
errors.email = "Enter a valid email";
}
if (form.password.length < 8) {
errors.password =
"Password must be at least 8 characters";
}
return errors;
};
const handleSubmit = async (event) => {
event.preventDefault();
const validationErrors = validate();
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
setErrors({});
setServerError("");
setIsSubmitting(true);
try {
const response = await fetch("/api/register", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(form),
});
if (!response.ok) {
throw new Error("Registration failed");
}
setForm(initialForm);
} catch (error) {
setServerError(
"Unable to create your account."
);
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="name">
Name
</label>
<input
id="name"
name="name"
value={form.name}
onChange={handleChange}
/>
{errors.name && (
<p>{errors.name}</p>
)}
</div>
<div>
<label htmlFor="email">
Email
</label>
<input
id="email"
name="email"
type="email"
value={form.email}
onChange={handleChange}
/>
{errors.email && (
<p>{errors.email}</p>
)}
</div>
<div>
<label htmlFor="password">
Password
</label>
<input
id="password"
name="password"
type="password"
value={form.password}
onChange={handleChange}
/>
{errors.password && (
<p>{errors.password}</p>
)}
</div>
{serverError && (
<p>{serverError}</p>
)}
<button
type="submit"
disabled={isSubmitting}
>
{isSubmitting
? "Creating..."
: "Create Account"}
</button>
</form>
);
}
export default RegisterForm;
Für die Produktion sollten außerdem auf fehlerhaften Feldern aria-invalid gesetzt werden und Nachrichten über aria-describedby verknüpft werden.
Der gesamte Ablauf in einem Überblick
Alles oben passt in einen Entscheidungsbaum – von der Eingabe über die Validierung bis hin zu Feldfehlern oder API-Aufrufen mit Erfolgs- und Fehlerrouten:
React Form
│
▼
User Input
│
▼
Form State
│
▼
Validation
/ \
Valid Invalid
│ │
▼ ▼
Submit Show Errors
│
▼
API Call
/ \
Success Error
│ │
▼ ▼
Reset/Form Show Server
Success UI Error
Wichtige Erkenntnisse
- Kontrollierte Eingaben machen den Zustand zur Quelle der Wahrheit; ein Objekt zusammen mit einem
[name]: value-Handler deckt die meisten Felder ab. - Zahlen kommen als Strings an, Checkboxen verwenden
checked, Auswahlmenüs werden über dasselect-Element gesteuert, und Dateieingaben sind stets unkontrolliert. - Klientenseitige Validierung dient dem Benutzer; nur serverseitige Validierung schützt die Daten.
- Der Zustand beim Senden des Modells wird explizit angegeben, und das Absenden wird während Anfragen deaktiviert.
- Fügen Sie React Hook Form und Zod hinzu, wenn die Anzahl der Felder, gemeinsame Regeln oder die Render-Kosten das Manuell-Verwalten des Zustands erschweren.