Typensichere Formulare in React
Julian
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.
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');
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.
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