Typensichere Formulare in React

Julian

typensichere-formulare-in-react

In den Anfängen von React waren Formulare eine Qual. onChange State einsammeln, Werte setzen und da wurden die Informationen noch gar nicht in ein Gesamtkonstrukt zusammengeführt. Große Formulare in React zu bauen, ist inzwischen dank Libraries wie React Hook Form deutlich einfacher geworden. Die Typsicherheit geht allerdings bei der Unterteilung in kleinere "Sub-Forms" leicht verloren und so kann es trotz TypeScript schnell passieren, dass statt einer Zahl doch ein String irgendwo landet und der Compiler nicht sofort Alarm schlägt. Hier kommt ein Deep-Dive in Formulare in React, und wie wir das Problem der typsicheren Sub-Forms mit unserem eigenen FormBuilder gelöst haben!

Controlled vs. Uncontrolled

Beginnen wir mit einer kleinen Einführung in ein wichtiges Konzept: In React gibt es zwei Modi, in denen Input-Elemente wie <input>, <select>, <textarea> operieren.

Controlled inputs erhalten ihren State aus einer übergeordneten Komponente. Das erkennt man daran, dass die value-Prop explizit angegeben wird. Kurz gefasst: "state goes in, events come out".

const Example = () => {
    const [name, setName] = useState('Giovanni Giorgio');

    return <input value={name} onChange={(e) => setName(e.currentTarget.value)} />;
};

Uncontrolled inputs sind im Gegensatz solche, für die value nicht definiert ist. Sie verwalten ihren State intern; von außen können wir daher lediglich Änderungen beobachten. Wenn wir Änderungen programmatisch hervorrufen wollen, dann müssen wir React umgehen und das Element über die DOM API ansprechen, z.B. mit einem ref.

const Example = () => {
    const ref = useRef<HTMLInputElement>(null);
    const setName = () => {
        if (ref.current) {
            ref.current.value = '(chka-chka) Slim Shady';
        }
    };

    return (
        <>
            <input ref={ref} onChange={(e) => console.log(`My name is: ${e.currentTarget.value}`)} />
            <button onClick={setName}>Set Name</button>
        </>
    );
};

"Vanilla" Forms

Für einfache Formulare, z.B. ein Kontaktformular oder Gästebuch (wer so etwas noch hat 😉) kommt man in React noch ohne weitere Hilfsmittel aus. Durch onSubmit auf dem <form> Element kriegen wir mit, wenn Daten bereit stehen. Diese ziehen wir uns dann mit FormData aus den einzelnen Elementen. Dafür muss zumindest ein name-Attribut gesetzt werden.

const Example = () => {
    const handleSubmit = (event) => {
        const formData = new FormData(event.currentTarget);
        console.log(`${formData.get('name')} sagt: ${formData.get('nachricht')}`);
    };

    return (
        <form onSubmit={handleSubmit}>
            <input name='name' />
            <input name='nachricht' />
            <button type='submit'>Abschicken</button>
        </form>
    );
};

Wenn das schon ausreicht, sind wir fertig. In allen anderen Fällen lohnt sich...

React Hook Form

Es gibt zahlreiche Libraries für React, die das Arbeiten mit Formularen für Entwickler ergonomischer gestalten:

Momentan ist React Hook Form mit 36,000 Sternen auf GitHub die beliebteste dieser Libraries und hat nach aktuellem Stand 11 offene Issues im Vergleich zu 663 in Formik. Wir sind vielleicht auch etwas voreingenommen 😉

Vom Grundprinzip erfüllen diese Libraries aber alle den gleichen Zweck: sie registrieren die nötigen Event-Handler und verwalten neben den aktuellen Eingabewerten im Formular auch andere nützliche Informationen, wie etwa:

  • Fehlerzustände (z.B. durch Validierung)
  • Abweichungen vom Ausgangszustand ("touched" oder "dirty fields")
  • Zustand des Formulars (in Bearbeitung, wird abgesendet, wurde abgesendet)

Im Folgenden wollen wir uns React Hook Form näher anschauen. Die API ist einfach zu bedienen: es gibt einen useForm Hook, mit dem wir ein "virtuelles" Formular erstellen - virtuell im dem Sinne, dass die Library selber keine DOM-Elemente erzeugt, sondern uns das nötige Werkzeug in die Hände legt (dieses Prinzip ist mittlerweile auch als "headless" bekannt geworden). Die zwei wichtigsten Funktionen, die React Hook Form bereitstellt, sind register und handleSubmit. Mit register können wir unsere eigenen Inputs mit dem Formular verknüpfen - es liefert eine Gruppe von Props zurück, die wir direkt auf das Input-Element anwenden können. Wir müssen lediglich angeben, auf welches Feld wir uns beziehen.

import {useForm} from 'react-hook-form';

const Example = () => {
    const {register, handleSubmit} = useForm();

    return (
        <form>
            <input {...register('name')} />
            <input {...register('lieblingsTier')} />
        </form>
    );
};

Was genau beinhaltet der Rückgabewert von register? Prüfen wir das.

console.log(register('name'));

// Object {name: "name", onChange: async onChange(event), onBlur: async onChange(event), ref: ref(ref)}

Neben der name-Prop, die dafür sorgt, dass das Input richtig zugeordnet werden kann, gibt es auch zwei Event-Handler, die bei Änderungen und Fokus-Verlust ("blur") aktiviert werden, und ein ref, mit dem Werte von react-hook-form programmatisch in die Inputs geschrieben werden können.

Info

Daran erkennen wir auch, dass React Hook Form für den Einsatz mit uncontrolled Inputs gedacht ist (value ist nicht definiert). Für controlled inputs gibt es jedoch auch einen speziellen Mechanismus.

Um dafür zu sorgen, dass auch etwas passiert, wenn das Formular abgeschickt wird, gibt es handleSubmit. Es nimmt ein Callback entgegen, in dem wir definieren, was mit den Ergebnissen geschehen soll (z.B. eine Backend-API aufrufen). Bevor React Hook Form dieses Callback ausführt, wird das Formular noch validiert. Beispielsweise werden fehlende Angaben (gekennzeichnet durch register('...', {required: true})) in einen entsprechenden Fehlerzustand übersetzt und das Feld wird automatisch fokussiert, um dem Anwender eine schnelle Korrektur zu ermöglichen.

import {useForm} from 'react-hook-form';

const Example = () => {
    const {register, handleSubmit} = useForm();
    const submit = handleSubmit((result) => console.log('Mach was mit:', result));

    return (
        <form onSubmit={submit}>
            <input {...register('name', {required: true})} />
            <input {...register('lieblingsTier')} />
            <button type='submit'>Ab die Post</button>
        </form>
    );
};

Üblicherweise wird der Submit-Handler direkt dem onSubmit des <form>-Elements zugewiesen. Es ist aber auch möglich, submit() manuell, also außerhalb von <form> aufzurufen.

Subforms

Irgendwann erreichen Anwendungen eine Größe, bei der sich wiederkehrende Strukturen identifizieren lassen. Nehmen wir als Beispiel einen Prozess, der die Eingabe einer Adresse erfordert - sei es eine Anmeldung, eine Bestellung, ein Kauf auf Rechnung. Je mehr von diesen Prozessen wir abbilden, desto mehr wiederholen wir uns, und spätestens beim dritten mal "Vorname, Nachname, Straße, Hausnummer, PLZ, Ort, etc." spüren wir das Bedürfnis, Teile unserer Formulare als eigenständige Komponenten auszulagern.

Wir wissen, dass wir die register-Funktion aus dem useForm()-Aufruf benötigen, um die einzelnen Inputs der Adresse zu verkabeln. Also verlangen wir es als Prop in der AdresseField-Komponente. Mit Context können wir register auch anderweitig bereitstellen, doch das lassen wir erst einmal außen vor.

import {type UseFormRegister} from 'react-hook-form';

const AdresseField = ({register}: {register: UseFormRegister<any>}) => {
    return (
        <>
            <input {...register('plz')} placeholder='PLZ' />
            <input {...register('ort')} placeholder='Ort' />
        </>
    );
};

Aktuell gehen wir noch davon aus, dass plz und ort direkt auf den Formular-Daten liegen, und nicht etwa in ein adresse-Objekt verpackt sind:

type FormData = {
    vorname: string;
    nachname: string;
    adresse: {ort: string; plz: string /* ... */};
    /* ... */
};

Wollen wir Letzteres unterstützen, dann fügen wir eine path-Prop hinzu, mit der wir verschachtelte Felder registrieren können. In unserem Fall würden wir 'adresse' als path angeben.

const AdresseField = ({register, prefix}: {register: UseFormRegister<any>; path: string}) => {
    const withPath = (name: string) => (path ? `${path}.${name}` : name);
    return (
        <>
            <input {...register(withPath('plz'))} placeholder='PLZ' />
            <input {...register(withPath('ort'))} placeholder='Ort' />
        </>
    );
};

So weit, so gut. Leider ist hier etwas wichtiges auf der Strecke geblieben: die Typensicherheit. Wie, was? Gehen wir noch einmal einen Schritt zurück. Grob gefasst erzeugt React Hook Form getypte Formulare. Oft kann die Typisierung eines Formulars direkt von den Standardwerten (defaultValues, sofern diese angegeben sind) abgeleitet werden; das hat zur Folge, dass TypeScript einen Aufruf von register() auf Feldern, die nicht existieren, zur compile time verhindert.

register('postleitzahl');

// Fehler:
// TS2345: Argument of type"postleitzahl"is not assignable to parameter of type "plz" | "ort"

Da sich in unserer Implementierung aber ein <any> eingeschlichen hat, sind wir vor diesem Szenario nicht mehr geschützt. Also fangen wir noch einmal von vorne an, und machen es diesmal richtig.

Subforms, aber typensicher

Zuerst definieren wir das Konzept "Adresse" als eigenständigen Typen.

type Adresse = {
    plz: string;
    ort: string;
};

Als Nächstes wollen wir den path eingrenzen, den das AdresseField erhalten soll, sodass wir nur noch Felder referenzieren können, die dieser Definition genügen würden. Genau dafür gibt es einen Hilfstypen aus react-hook-form, FieldPathByValue. Da dieser aber kontextuell von der Typendefinition des Formulars als ganzes abhängig ist, müssen wir unsere Komponente generisch (auf TForm) gestalten. Glücklicherweise wird dieser generische Parameter von TypeScript durch Inferenz aus der register-Prop automatisch gewählt.

import {type UseFormRegister, type FieldPath, type FieldPathByValue} from 'react-hook-form';

const AdresseField = <TForm extends FieldValues>({register, path}: {register: UseFormRegister<TForm>; path: FieldPathByValue<TForm, {ort: string; plz: string}>}) => {
    const withPath = (name: FieldPath<Address>) => (path ? `${path}.${name}` : name) as FieldPath<TForm>;
    return (
        <>
            <input {...register(withPath('ort'))} placeholder='Name' />
            <input {...register(withPath('plz'))} name='name' placeholder='Name' />
        </>
    );
};

Außerdem grenzen wir name in withPath(name) auf die Eigenschaften von Adresse ein, sodass der daraus entstehende Pfad garantiert auf einen gültigen Teil des Formulars zeigt. Dies versichern wir dem Compiler durch den Cast auf FieldPath<TForm>.

const Example = () => {
    const form = useForm({defaultValues: {adresse: {ort: '', plz: ''}}});
    return (
        <form>
            <AdresseField register={form.register} path='adresse' />
        </form>
    );
};

Weil wir uns nicht zum ersten Mal mit dem Thema der Typensicherheit in Formularen auseinandersetzen (und bestimmt auch nicht zum letzten Mal), haben wir unsere Erkenntnisse in einer Library zusammengefasst, die als Wrapper für React Hook Form eine typensichere API bereitstellt. Natürlich open-source.

FormBuilder

Mit FormBuilder können wir beliebige Abschnitte eines Formulars steuern und als Prop an untergeordnete Komponenten weiterreichen (effektiv bringen wir register und path-Props aus der vorigen Implementierung unter einem Dach zusammen).

Zum Beispiel:

import {useFormBuilder, type FormBuilder} from '@atmina/formbuilder';

const AdresseField = ({on: field}: {on: FormBuilder<Adresse>}) => (
    <>
        <input {...field.plz()} />
        <input {...field.ort()} />
    </>
);

const Example = () => {
    const {fields} = useFormBuilder({defaultValues: {adresse: {plz: '', ort: ''}}});
    return <AdresseField on={fields.adresse} />;
};

Das Aufrufen von field bzw. eines untergeordneten Objektes (z.B. field.ort()) ist äquivalent zu register(). Zusätzlich befinden sich darauf auch Methoden, mit denen wir den aktuellen Wert und Fehlerzustände beobachten können, oder den Wert programmatisch überschreiben können.

const currentValue = field.$useWatch(); // Dies ist ein Hook
const {errors, dirty} = field.$useState(); // Das auch
field.$setValue('Neuer Wert');
Info

Der $-Präfix verhindert namentliche Überschneidungen mit tatsächlichen Werten im Formular und erinnert uns an ein vergangenes Zeitalter.

FormBuilder reduziert nicht nur Schreibarbeit: Da FormBuilder jeden Teilabschnitt in Isolation betrachtet, können wir sicherstellen, dass wir innerhalb einer Subform-Komponente nur den Zustand des entsprechenden Teilabschnitts sehen bzw. beeinflussen können. Ein absichtliches Auslesen oder versehentliches Überschreiben von unabhängigen Werten ist also ausgeschlossen (Separation of concerns).

// Vanilla react-hook-form
const {setValue} = useFormContext();
setValue('zahlungsart.kreditkarte', 'Ich kann beliebige Werte verändern! 😈');

// FormBuilder
const {$setValue} = field;
$setValue('ich kann nur meinen eigenen Wert ändern! 😇');

Übrigens: In FormBuilder können untergeordnete Felder durch Punkt-Notation ausgewählt werden (field.foo.bar.baz), obwohl die Typisierung des Formulars ausschließlich in TypeScript existiert und nicht im transpilierten JavaScript Code. Möglich wird diese Art der API dadurch, dass Felder in FormBuilder durch die Verwendung von Proxies quasi "on-demand" erzeugt werden. Welche Felder aber letztendlich referenziert werden können, ohne dass die IDE einen Fehler wirft, entscheidet das Typensystem.

Non-string fields

Ein Thema, das wir uns bisher noch nicht angeschaut haben, sind Felder, die keine Strings enthalten. Ein Beispiel dafür ist ein Zahlenfeld, welches wir hier mit FormBuilder umsetzen werden:

const NumericField = ({on: field}: {on: FormBuilder<number>}) => {
    return <input type='number' {...field({valueAsNumber: true})} />;
};

Mit type='number' sagen wir dem Input, dass nur Zahlen akzeptiert werden sollen. Das valueAsNumber: true sorgt dafür, dass React Hook Form den Wert als Zahl interpretiert. Als Hintergrund dazu: alle HTML Inputs haben als value standardmäßig immer einen String, auch wenn type='number' gesetzt ist. Auf den "Number" und "Date" Inputs gibt es stattdessen ein valueAsNumber und valueAsDate-Attribut, welches den Value als entsprechenden Typ (also Number bzw. Date) enthält.

Warnung

Postleitzahlen und Telefonnummern sollten allerdings nicht als Zahlen behandelt werden! Als Faustregel gilt: nur jene Werte, bei denen das Inkrementieren bzw. Dekrementieren Sinn ergibt, sind Zahlen.

Fazit

Für einfache Use-Cases kommt man mit "Vanilla" React und uncontrolled inputs schon ziemlich weit. Für größere Formulare, die auch einen Anspruch an Validierung und Fehlerzuständen haben, sollte man zusätzlich eine einschlägige Library in Erwägung ziehen. Wir haben in unseren Projekten gute Erfahrungen mit React Hook Form gemacht, wünschten uns aber eine bessere Developer Experience hinsichtlich der Typensicherheit in aufgebrochenen Form-Komponenten. Daraus ist FormBuilder, eine Erweiterung von React Hook Form, entstanden. Wir hoffen, das ihr euch etwas aus diesen verschiedenen Ansätzen mitnehmen könnt!

Themen:

  • Allgemein
  • Programmiersprachen
  • Softwareentwicklung
  • Von Entwickler zu Entwickler