Formular-Logik wiederholt sich in jeder React-App: State pro Feld, Change-Handler, Blur-Handler für Touched-Tracking, Validierung pro Feld, Reset beim Submit. Ein Custom Hook useForm bündelt all das in einer wiederverwendbaren Funktion — die Komponente kümmert sich nur noch um JSX und Layout, nicht um Form-Mechanik. Dieser Artikel baut useForm Schritt für Schritt auf, erklärt jedes Detail (warum Touched-Tracking, warum funktionale Setter, warum Object-Rückgabe) und zeigt den vollständigen Einsatz in einem Registrierungs-Formular. Wer am Ende mehrere Form-Pakete im Projekt hat, sollte sich später React Hook Form anschauen — aber useForm als Eigenbau ist die beste Grundlage, um zu verstehen, was Form-Libraries unter der Haube tun.
Was der Hook leisten soll
Bevor wir Code schreiben, definieren wir, was useForm aus Konsumenten-Sicht können soll:
- Werte verwalten pro Feldname —
fieldValues.email,fieldValues.password, … - Change-Handler liefern, der mit allen üblichen Feld-Typen klarkommt (Text, Email, Password, Checkbox, später ggf. Radio/Select).
- Touched-Tracking — Felder werden erst als „berührt" markiert, wenn der User sie verlassen hat. Fehlermeldungen sollen erst nach Verlassen erscheinen, nicht beim ersten Tastendruck.
- Validierungs-Ergebnis als Object
{ feldname: 'Fehlermeldung' }— leer wenn alles ok. - Reset des gesamten Formulars auf die Initial-Werte zurück.
- Programmatisches Setzen einzelner Werte (z.B. aus einer Server-Antwort).
Daraus ergeben sich der Parameter-Vertrag und die Rückgabe-Form, die wir im Hook abbilden.
Der Hook im Detail
import { useState } from 'react';
export function useForm(initialValues, validate) {
// 1. State für die aktuellen Feld-Werte
const [fieldValues, setFieldValues] = useState(initialValues);
// 2. Validierungs-Fehler als Object {feldname: 'Fehlermeldung'}
const [fieldErrors, setFieldErrors] = useState({});
// 3. Welche Felder hat der User schon verlassen (für Fehler-Anzeige-Timing)
const [fieldsTouched, setFieldsTouched] = useState({});
// 4. Change-Handler — eine Funktion für ALLE Felder via name-Attribut
const handleFieldChange = (event) => {
const { name, value, type, checked } = event.target;
// Checkboxen liefern in `checked`, alle anderen in `value`
const newValue = type === 'checkbox' ? checked : value;
// Funktionaler Setter — kein Stale-Closure
setFieldValues(prev => ({
...prev,
[name]: newValue,
}));
// Direkt validieren, falls validate-Funktion da
if (validate) {
// Wichtig: NICHT mit prev arbeiten — wir brauchen den NEUEN State,
// den setFieldValues noch nicht commited hat. Daher den Wert manuell mergen.
const nextValues = { ...fieldValues, [name]: newValue };
setFieldErrors(validate(nextValues));
}
};
// 5. Blur-Handler — markiert ein Feld als 'touched'
const handleFieldBlur = (event) => {
const { name } = event.target;
setFieldsTouched(prev => ({ ...prev, [name]: true }));
};
// 6. Reset auf Initial-Werte zurück
const handleFormReset = () => {
setFieldValues(initialValues);
setFieldErrors({});
setFieldsTouched({});
};
// 7. Programmatisches Setzen — z.B. nach Server-Antwort
const setFieldValue = (name, value) => {
setFieldValues(prev => ({ ...prev, [name]: value }));
};
// 8. Alle Felder als touched markieren (für Submit-Zeit)
const markAllTouched = () => {
const allTouched = {};
Object.keys(initialValues).forEach(key => {
allTouched[key] = true;
});
setFieldsTouched(allTouched);
};
// 9. Prüft, ob das Formular insgesamt valid ist
const isValid = validate
? Object.keys(validate(fieldValues)).length === 0
: true;
return {
fieldValues,
fieldErrors,
fieldsTouched,
isValid,
handleFieldChange,
handleFieldBlur,
handleFormReset,
setFieldValue,
markAllTouched,
};
}Wichtige Designentscheidungen, durchnummeriert:
-
useStatemitinitialValues-Object. Konsumenten geben einen Object-Initial mit, der gleichzeitig die Form-Schema dokumentiert (welche Felder gibt es). Beim Reset wandert dasselbe Object zurück. -
fieldErrorsals separates State-Object. Trennt sauber zwischen „der User hat eingegeben" und „was er eingegeben hat, ist falsch". Ein leeres Object heißt „keine Fehler". -
fieldsTouchedfür UX-Timing. Validierungs-Fehler werden technisch sofort berechnet, aber erst angezeigt, wenn der User das Feld verlassen hat. Das verhindert, dass beim ersten Tastendruck auf einem leeren Feld sofort „erforderlich"-Meldungen erscheinen. -
Ein generischer Change-Handler für alle Feld-Typen. Der Trick ist das
name-Attribut am Input plus die Verzweigung auftype === 'checkbox'. Mit diesem Pattern kommt man für 90 % aller Form-Felder ohne pro-Feld-Handler aus. -
Blur-Handler unabhängig vom Change-Handler. Touched-Tracking ist UX-orientiert (wann zeigen wir Fehler), nicht zustand-orientiert.
-
Reset setzt alle drei State-Slots zurück — Werte, Fehler und Touched. Damit ist das Formular wirklich „wie neu".
-
setFieldValuefür externe Sets. Praktisch, wenn man Werte aus einer URL, einem Server-Response oder einer anderen Komponente nachträglich setzen will. -
markAllTouchedist die Submit-Zeit-Brücke: wenn der User „Senden" klickt, ohne alle Felder verlassen zu haben, sollen trotzdem alle Fehler sichtbar werden. -
isValidist abgeleitet, nicht eigener State — bei jedem Render frisch berechnet. Vermeidet Sync-Bugs zwischen „echten" Fehlern und einer redundanten Boolean-Variable.
Validierungs-Funktion
Die Validierung ist absichtlich außerhalb des Hooks — der Hook weiß nicht, was eine valide Email ist; das ist Anwendungs-Logik. Stattdessen nimmt der Hook eine validate-Funktion entgegen und ruft sie bei jedem Change auf.
Die Funktion bekommt das aktuelle fieldValues-Object und gibt ein errors-Object zurück — Schlüssel sind Feld-Namen, Werte sind Fehlermeldungen.
export function validateRegistrationForm(values) {
const errors = {};
// Benutzername
if (!values.fieldUsername) {
errors.fieldUsername = 'Benutzername ist erforderlich';
} else if (values.fieldUsername.length < 3) {
errors.fieldUsername = 'Benutzername muss mindestens 3 Zeichen lang sein';
}
// E-Mail
if (!values.fieldEmail) {
errors.fieldEmail = 'E-Mail ist erforderlich';
} else if (!/\S+@\S+\.\S+/.test(values.fieldEmail)) {
errors.fieldEmail = 'E-Mail-Adresse ist ungültig';
}
// Passwort
if (!values.fieldPassword) {
errors.fieldPassword = 'Passwort ist erforderlich';
} else if (values.fieldPassword.length < 6) {
errors.fieldPassword = 'Passwort muss mindestens 6 Zeichen lang sein';
}
// AGB
if (!values.fieldTerms) {
errors.fieldTerms = 'Sie müssen die AGB akzeptieren';
}
return errors;
}Bemerkung: das ist eine handgeschriebene Validierung, die für eine Lern-Demo völlig ausreicht. In Produktion lohnt sich oft Zod oder Yup als Schema-Validator — die Regel-Definition wird deklarativ, und Type-Safety in TypeScript ergibt sich automatisch.
Das Formular einsetzen
Die Komponente nutzt den Hook, definiert das JSX und übergibt die Handler an die Inputs. Die Komponente weiß nichts mehr von State-Verwaltung — nur noch davon, welche Felder es gibt und wie sie aussehen.
import { useForm } from './useForm';
import { validateRegistrationForm } from './validateRegistrationForm';
const initialValues = {
fieldUsername: '',
fieldEmail: '',
fieldPassword: '',
fieldTerms: false,
};
export default function RegistrationForm() {
const {
fieldValues,
fieldErrors,
fieldsTouched,
isValid,
handleFieldChange,
handleFieldBlur,
handleFormReset,
markAllTouched,
} = useForm(initialValues, validateRegistrationForm);
const handleSubmit = (event) => {
event.preventDefault();
// Alle Felder als touched markieren, damit alle Fehler sichtbar werden
markAllTouched();
// Nur absenden, wenn validiert
if (!isValid) return;
console.log('Formular abgesendet:', fieldValues);
handleFormReset();
};
// Helper: Fehler nur zeigen, wenn das Feld berührt wurde
const fieldError = (name) =>
fieldsTouched[name] ? fieldErrors[name] : null;
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="fieldUsername">Benutzername</label>
<input
type="text"
id="fieldUsername"
name="fieldUsername"
value={fieldValues.fieldUsername}
onChange={handleFieldChange}
onBlur={handleFieldBlur}
placeholder="Mind. 3 Zeichen"
aria-invalid={!!fieldError('fieldUsername')}
aria-describedby="fieldUsername-error"
/>
{fieldError('fieldUsername') && (
<p id="fieldUsername-error" role="alert">
{fieldError('fieldUsername')}
</p>
)}
</div>
<div>
<label htmlFor="fieldEmail">E-Mail</label>
<input
type="email"
id="fieldEmail"
name="fieldEmail"
value={fieldValues.fieldEmail}
onChange={handleFieldChange}
onBlur={handleFieldBlur}
placeholder="example@mail.com"
aria-invalid={!!fieldError('fieldEmail')}
/>
{fieldError('fieldEmail') && <p role="alert">{fieldError('fieldEmail')}</p>}
</div>
<div>
<label htmlFor="fieldPassword">Passwort</label>
<input
type="password"
id="fieldPassword"
name="fieldPassword"
value={fieldValues.fieldPassword}
onChange={handleFieldChange}
onBlur={handleFieldBlur}
placeholder="Mind. 6 Zeichen"
aria-invalid={!!fieldError('fieldPassword')}
/>
{fieldError('fieldPassword') && <p role="alert">{fieldError('fieldPassword')}</p>}
</div>
<div>
<label>
<input
type="checkbox"
name="fieldTerms"
checked={fieldValues.fieldTerms}
onChange={handleFieldChange}
onBlur={handleFieldBlur}
/>
Ich akzeptiere die AGB
</label>
{fieldError('fieldTerms') && <p role="alert">{fieldError('fieldTerms')}</p>}
</div>
<button type="submit">Registrieren</button>
<button type="button" onClick={handleFormReset}>Zurücksetzen</button>
</form>
);
}Was hier gut zu sehen ist:
onSubmitam<form>, nichtonClickam Button. Damit greift auch die Enter-Taste in einem Input — Browser-Standard-Verhalten bleibt erhalten.event.preventDefault()im Submit-Handler verhindert das Browser-Standard-Reload.fieldError-Helper kombiniert „Touched UND Error vorhanden" in eine kompakte Lese-Form.- Accessibility-Attribute (
aria-invalid,aria-describedby,role="alert") sind kein Add-on, sondern Pflicht für Forms — Screen-Reader-User müssen Fehler genauso wahrnehmen wie sehende User.

Wo dieser Hook an Grenzen kommt
Das useForm-Beispiel deckt 80 % der Formular-Anforderungen ab. Bei den restlichen 20 % stößt es an Grenzen, die Form-Libraries spezifisch lösen:
-
Performance bei großen Formularen. Jeder Tastendruck triggert einen Re-Render der gesamten Formular-Komponente. Bei 50+ Feldern oder komplexen Inputs (z.B. Rich Text Editor) ist das spürbar. React Hook Form löst das mit einem uncontrolled Ansatz — Werte werden via refs gelesen, nicht via State.
-
Field-Arrays. Listen von Feldern (z.B. „beliebig viele Telefonnummern hinzufügen") brauchen eine andere State-Struktur als ein flaches Object. Form-Libraries haben dafür spezielle
useFieldArray-Hooks. -
Async-Validierung. „Ist dieser Benutzername schon vergeben?" verlangt einen Server-Call. Unser einfacher
validate(values)ist synchron — bei Async braucht's Debouncing, Cancel-Logik, separates Loading-State. -
Schema-Validation. Bei vielen Feldern wird die imperative Validierungs-Funktion unhandlich. Zod oder Yup erlauben deklarative Schemas mit automatischer Type-Inference in TypeScript.
-
Field-Level vs. Form-Level Errors. Unser Hook kennt nur Feld-Errors. Form-Level-Errors („Diese Kombination ist ungültig") müsste man manuell modellieren.
Faustregel: useForm als Eigenbau ist für 2-5 Felder und einfache Validierung perfekt. Ab 6+ Feldern lohnt sich React Hook Form mit Zod-Schema.
Besonderheiten
Ein generischer Change-Handler via name-Attribut spart pro-Feld-Handler.
Statt 10 verschiedene handleEmailChange, handlePasswordChange usw. — ein einziger Handler, der via event.target.name das passende Feld findet. Pflicht: am Input das name-Attribut setzen.
Funktionaler Setter setFieldValues(prev => ...) vermeidet Stale-Closures.
Direkter Wert setFieldValues({...fieldValues, [name]: newValue}) liest den fieldValues aus dem aktuellen Render — der kann veraltet sein, wenn mehrere Setter im selben Event laufen. Funktionaler Setter liest immer den aktuellen State.
Validierung MIT manuellem Merge bei Change-Trigger.
Im Change-Handler ruft validate({...fieldValues, [name]: newValue}) — NICHT validate(fieldValues). Der Setter ist asynchron, der State im selben Funktions-Body ist noch der ALTE. Manueller Merge gibt die korrekten Daten an die Validierung.
Touched-Tracking trennt Validierungs-Logik von UI-Timing.
Validierung läuft technisch sofort. Touched entscheidet, ob die Fehler-Anzeige bereits sichtbar wird. So bekommt der User keine „erforderlich"-Meldung beim ersten Zeichen, sondern erst beim Verlassen des leeren Felds.
isValid abgeleitet, nicht eigenständig.
Direkt aus validate(fieldValues) berechnen, nicht als eigenen State pflegen. Doppelter State läuft fast immer auseinander. Faustregel: alles, was sich aus anderem State ableiten lässt, gehört nicht in useState.
markAllTouched beim Submit zeigt alle Fehler.
Wenn der User „Senden" klickt, ohne einzelne Felder berührt zu haben, würde Touched-Tracking die Fehler weiterhin verstecken. Beim Submit alle als touched markieren — dann werden alle Fehler sichtbar.
onSubmit am `
Fängt auch Enter-Tastendruck in Inputs ab — Browser-Standard. onClick am Submit-Button greift nur beim Klick. event.preventDefault() im Handler ist Pflicht, sonst Page-Reload.
Ab 6+ Feldern lohnt sich React Hook Form mit Zod.
Eigenbau-useForm bleibt einfach für Lern- und Klein-Use-Cases. Bei größeren Formularen: Performance via uncontrolled inputs (RHF), deklarative Schemas (Zod), Field-Arrays, Async-Validation — alles vorgelöst.
Weiterführende Ressourcen
Externe Quellen
- Reusing Logic with Custom Hooks – react.dev
<form>and Actions – react.dev- React Hook Form – Documentation
- Zod – Schema Validation
- ARIA Authoring Practices: Forms