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

TypeScript useForm.js
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:

  1. useState mit initialValues-Object. Konsumenten geben einen Object-Initial mit, der gleichzeitig die Form-Schema dokumentiert (welche Felder gibt es). Beim Reset wandert dasselbe Object zurück.

  2. fieldErrors als separates State-Object. Trennt sauber zwischen „der User hat eingegeben" und „was er eingegeben hat, ist falsch". Ein leeres Object heißt „keine Fehler".

  3. fieldsTouched fü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.

  4. Ein generischer Change-Handler für alle Feld-Typen. Der Trick ist das name-Attribut am Input plus die Verzweigung auf type === 'checkbox'. Mit diesem Pattern kommt man für 90 % aller Form-Felder ohne pro-Feld-Handler aus.

  5. Blur-Handler unabhängig vom Change-Handler. Touched-Tracking ist UX-orientiert (wann zeigen wir Fehler), nicht zustand-orientiert.

  6. Reset setzt alle drei State-Slots zurück — Werte, Fehler und Touched. Damit ist das Formular wirklich „wie neu".

  7. setFieldValue für externe Sets. Praktisch, wenn man Werte aus einer URL, einem Server-Response oder einer anderen Komponente nachträglich setzen will.

  8. markAllTouched ist die Submit-Zeit-Brücke: wenn der User „Senden" klickt, ohne alle Felder verlassen zu haben, sollen trotzdem alle Fehler sichtbar werden.

  9. isValid ist 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.

TypeScript validateRegistrationForm.js
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.

TypeScript RegistrationForm.jsx
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:

  • onSubmit am <form>, nicht onClick am 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.

React Custom Hook - Beispiel Verwendung mit Formular

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 `
`, nicht `onClick` am Button.

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

/ Weiter

Zurück zu Custom Hooks

Zur Übersicht