Dynamische Formularfelder verwalten, ohne bei jedem Tastendruck die ganze Liste neu zu rendern
Adresslisten, Rechnungspositionen oder Kontaktpersonen: Sobald ein Formular eine variable Anzahl gleichartiger Eintraege braucht, wird manueller Array-State schnell zur Performance-Bremse. useFieldArray aus React Hook Form loest genau dieses Problem gezielt statt pauschal.
Inhaltsverzeichnis
- 1. Das Problem mit manuellem Array-State
- 2. Die API im Ueberblick
- 3. Praxisbeispiel: Mehrere Lieferadressen
- 4. Praxisbeispiel: Rechnungspositionen mit Live-Berechnung
- 5. Warum das schneller ist als manueller Array-State
- 6. Validierung pro Feld-Eintrag
- 7. Umsortieren mit move und swap
- 8. Verschachtelte Field Arrays
- 9. Haeufige Fehler bei useFieldArray
- 10. Zusammenfassung
- 11. FAQ
1. Das Problem mit manuellem Array-State
Ein Formular fuer mehrere Lieferadressen oder Rechnungspositionen braucht eine Liste von Eintraegen, die der Nutzer hinzufuegen und entfernen kann. Der naheliegende Ansatz mit useState haelt die gesamte Liste in einem einzigen State-Wert, jede Aenderung eines einzelnen Feldes, etwa ein Tastendruck in einem Strassennamen-Input, ersetzt dabei das komplette Array durch eine neue Kopie und loest damit ein Re-Render der gesamten Liste aus, nicht nur der betroffenen Zeile.
Bei zehn oder mehr Eintraegen wird dieser Effekt spuerbar: Jeder Tastendruck rendert alle Zeilen neu, obwohl sich nur ein einziges Zeichen in einem einzigen Feld geaendert hat. React Hook Form verfolgt einen anderen Ansatz und arbeitet standardmaessig uncontrolled, also ueber Refs statt ueber State-Updates pro Tastenanschlag. useFieldArray ueberträgt dieses Prinzip konsequent auf Arrays von Feldern und haelt Re-Renders auf das strukturell Noetige beschraenkt.
2. Die API im Ueberblick
useFieldArray({ control, name }) liefert ein Objekt mit dem aktuellen fields-Array sowie den Methoden append, remove, insert, update, move und swap. Jedes Element in fields enthaelt neben den eigentlichen Formularwerten eine von React Hook Form generierte, stabile id-Eigenschaft, die sich unabhaengig von der Position im Array nicht aendert.
Genau diese id muss als React key fuer jede gerenderte Zeile verwendet werden, niemals der Array-Index. Wird stattdessen der Index als Key genutzt, kann React beim Entfernen oder Einfuegen eines Eintrags in der Mitte der Liste bestehende DOM-Nodes und damit auch die internen Werte der unkontrollierten Inputs falschen Zeilen zuordnen, ein subtiler Bug, bei dem plötzlich falsche Werte in falschen Feldern stehen.
import { useForm, useFieldArray } from 'react-hook-form';
function AddressForm() {
const { control, register, handleSubmit } = useForm({
defaultValues: { addresses: [{ street: '', city: '' }] },
});
const { fields, append, remove } = useFieldArray({
control,
name: 'addresses',
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
{fields.map((field, index) => (
<div key={field.id}>
<input {...register(`addresses.${index}.street`)} />
<input {...register(`addresses.${index}.city`)} />
<button type="button" onClick={() => remove(index)}>
Entfernen
</button>
</div>
))}
<button type="button" onClick={() => append({ street: '', city: '' })}>
Adresse hinzufuegen
</button>
</form>
);
}
3. Praxisbeispiel: Mehrere Lieferadressen
Bei einer Adressliste registriert jede Zeile ihre eigenen Felder ueber einen dynamischen Pfad wie addresses.${index}.street, wobei index aus der Position im fields-Array kommt. append({ street: '', city: '' }) fuegt eine neue leere Zeile hinzu, remove(index) entfernt einen Eintrag anhand seiner aktuellen Position, beides reaktiv ohne einen kompletten Formular-Reset auszuloesen.
Wichtig ist, die initialen Eintraege bereits ueber defaultValues im umschliessenden useForm-Aufruf zu setzen, damit die erste Zeile oder mehrere vorbefuellte Zeilen korrekt erscheinen, bevor der Nutzer ueberhaupt interagiert. Nachtraegliche Aenderungen an der Liste laufen danach ausschliesslich ueber die Methoden von useFieldArray, nicht ueber direkte Manipulation von defaultValues.
function AddressList() {
const { control, register } = useForm({
defaultValues: {
addresses: [{ street: 'Hauptstrasse 1', city: 'Berlin' }],
},
});
const { fields, append, remove } = useFieldArray({ control, name: 'addresses' });
return (
<>
{fields.map((field, index) => (
<fieldset key={field.id}>
<legend>Adresse {index + 1}</legend>
<input {...register(`addresses.${index}.street`)} placeholder="Strasse" />
<input {...register(`addresses.${index}.city`)} placeholder="Stadt" />
{fields.length > 1 && (
<button type="button" onClick={() => remove(index)}>Entfernen</button>
)}
</fieldset>
))}
<button type="button" onClick={() => append({ street: '', city: '' })}>
Weitere Adresse
</button>
</>
);
}
4. Praxisbeispiel: Rechnungspositionen mit Live-Berechnung
Rechnungspositionen bestehen typischerweise aus verschachtelten Objekten mit Artikelbezeichnung, Menge und Einzelpreis. Fuer eine Live-Berechnung der Zeilensumme reicht ein globales watch() auf das gesamte Formular nicht aus, weil dann jede Aenderung in irgendeiner Zeile die Berechnung aller Zeilen neu ausloest.
useWatch mit einem gezielten name wie items.${index}.quantity beobachtet ausschliesslich diese eine Eigenschaft dieser einen Zeile und reduziert die Re-Render-Flaeche entsprechend auf genau diese Komponente. Bei zehn oder mehr Positionen ist der Unterschied zwischen globalem watch() und gezieltem useWatch pro Zeile deutlich in der wahrgenommenen Reaktionsgeschwindigkeit spuerbar.
function InvoiceRow({ control, index, remove }) {
const quantity = useWatch({ control, name: `items.${index}.quantity` });
const price = useWatch({ control, name: `items.${index}.price` });
const total = (Number(quantity) || 0) * (Number(price) || 0);
return (
<div>
<input type="number" {...control.register(`items.${index}.quantity`)} />
<input type="number" {...control.register(`items.${index}.price`)} />
<span>{total.toFixed(2)} EUR</span>
<button type="button" onClick={() => remove(index)}>Entfernen</button>
</div>
);
}
5. Warum das schneller ist als manueller Array-State
React Hook Form haelt Eingabewerte primaer in einer internen Ref-Struktur statt in React-State. Ein Re-Render wird nur dann ausgeloest, wenn eine Komponente sich explizit ueber watch, useWatch oder formState auf genau diesen Wert abonniert hat, alle anderen Komponenten bleiben davon vollstaendig unberuehrt.
useFieldArray selbst rendert die Elternkomponente nur bei strukturellen Aenderungen der Liste neu, also bei append, remove, insert, move oder swap. Tastatureingaben in einzelnen Inputs bleiben lokal isoliert und loesen kein Re-Render der Liste aus, das ist der zentrale Performance-Vorteil gegenueber einem mit useState verwalteten Array, bei dem jede Zeichentaste die gesamte Liste neu rendert.
6. Validierung pro Feld-Eintrag
Fuer die Validierung eignet sich ein Schema-basierter Resolver wie zodResolver, kombiniert mit einem Zod-Schema, das ein Array von Objekten beschreibt: z.array(z.object({ street: z.string().min(1), city: z.string().min(1) })). Fehler landen dann strukturell passend unter errors.addresses[index].street und lassen sich direkt bei der jeweiligen Zeile anzeigen.
Zusaetzlich zur Validierung einzelner Felder laesst sich auch die Array-Laenge selbst pruefen, etwa mit .min(1) auf dem Array-Schema, um sicherzustellen, dass mindestens ein Eintrag vorhanden ist. Ein solcher Array-Level-Fehler erscheint separat von den Feld-Fehlern der einzelnen Zeilen und muss an geeigneter Stelle, meist oberhalb der Liste, eigens angezeigt werden.
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
const schema = z.object({
addresses: z.array(
z.object({
street: z.string().min(1, 'Strasse erforderlich'),
city: z.string().min(1, 'Stadt erforderlich'),
})
).min(1, 'Mindestens eine Adresse erforderlich'),
});
function AddressForm() {
const { control, register, formState: { errors } } = useForm({
resolver: zodResolver(schema),
defaultValues: { addresses: [{ street: '', city: '' }] },
});
const { fields } = useFieldArray({ control, name: 'addresses' });
return fields.map((field, index) => (
<div key={field.id}>
<input {...register(`addresses.${index}.street`)} />
{errors.addresses?.[index]?.street && (
<span>{errors.addresses[index].street.message}</span>
)}
</div>
));
}
7. Umsortieren mit move und swap
move(from, to) verschiebt einen Eintrag von einer Position an eine andere, swap(a, b) vertauscht zwei Positionen direkt miteinander. Beide Methoden arbeiten intern ueber die stabile id-Struktur der Feld-Eintraege, ein manuelles Splicing des Arrays mit anschliessendem setValue-Aufruf ist dafuer nicht noetig und wuerde die interne Konsistenz von React Hook Form gefaehrden.
In Kombination mit einer Drag-and-Drop-Bibliothek wie dnd-kit reicht es meist, im onDragEnd-Callback die neue Ziel-Position zu ermitteln und dort move(oldIndex, newIndex) aufzurufen. React Hook Form uebernimmt danach die korrekte Neuordnung der internen Feld-Referenzen, ohne dass Werte einzelner Inputs verloren gehen oder vertauscht werden.
function ReorderableList() {
const { control } = useForm({ defaultValues: { items: [{ label: 'A' }, { label: 'B' }, { label: 'C' }] } });
const { fields, move } = useFieldArray({ control, name: 'items' });
return fields.map((field, index) => (
<div key={field.id}>
<span>{field.label}</span>
<button type="button" disabled={index === 0} onClick={() => move(index, index - 1)}>
Nach oben
</button>
<button type="button" disabled={index === fields.length - 1} onClick={() => move(index, index + 1)}>
Nach unten
</button>
</div>
));
}
8. Verschachtelte Field Arrays
Manche Formulare brauchen Listen innerhalb von Listen, etwa eine Rechnungsposition mit einer eigenen Unterliste von Rabatten. Das erfordert zwei gekoppelte useFieldArray-Aufrufe: einen aeusseren fuer die Rechnungspositionen und einen inneren pro Zeile fuer die jeweiligen Rabatte dieser Position.
Der Pfad des inneren Arrays muss dynamisch aus dem aeusseren Index gebildet werden, etwa items.${index}.discounts. Damit der innere Hook korrekt isoliert bleibt und nicht versehentlich auf die falsche Zeile zugreift, empfiehlt sich eine eigene Unterkomponente pro Zeile, die den Index als Prop erhaelt und darin ihren eigenen useFieldArray-Aufruf kapselt.
function InvoiceItemRow({ control, itemIndex }) {
const { fields, append, remove } = useFieldArray({
control,
name: `items.${itemIndex}.discounts`,
});
return (
<div>
{fields.map((discount, dIndex) => (
<div key={discount.id}>
<input {...control.register(`items.${itemIndex}.discounts.${dIndex}.percent`)} />
<button type="button" onClick={() => remove(dIndex)}>Rabatt entfernen</button>
</div>
))}
<button type="button" onClick={() => append({ percent: 0 })}>
Rabatt hinzufuegen
</button>
</div>
);
}
9. Haeufige Fehler bei useFieldArray
Der haeufigste Fehler ist die Verwendung des Array-Index als React key statt fields[i].id. Beim Entfernen oder Einfuegen mitten in der Liste fuehrt das dazu, dass React bestehende DOM-Nodes falschen logischen Eintraegen zuordnet, unkontrollierte Inputs behalten dann ihren alten, jetzt falschen Wert, obwohl das zugrunde liegende Datenmodell korrekt aktualisiert wurde.
Ein zweiter verbreiteter Fehler ist das asynchrone Nachladen von defaultValues nach einem API-Aufruf, ohne anschliessend reset() aufzurufen. useFieldArray bleibt dann leer, weil die urspruenglichen defaultValues beim ersten Render bereits fixiert wurden. Dazu kommt haeufig fehlendes Verstaendnis von shouldUnregister bei bedingt gerenderten Zeilen, was dazu fuehren kann, dass Werte entfernter Felder ungewollt im Formular-State erhalten bleiben.
| Ansatz | Re-Render bei Feldaenderung | Validierung pro Eintrag | Code-Aufwand |
|---|---|---|---|
| Manueller Array-State mit useState | Gesamte Liste bei jedem Tastendruck | Manuell pro Feld selbst geschrieben | Hoch, viel Boilerplate |
| useFieldArray ohne Resolver | Nur bei append, remove, insert, move, swap | Manuell in onSubmit oder onBlur | Mittel |
| useFieldArray mit zodResolver | Nur bei strukturellen Aenderungen | Deklarativ ueber Schema, inklusive Array-Laenge | Gering |
| useFieldArray mit verschachtelten Arrays | Isoliert pro Zeile durch gekoppelte Hooks | Deklarativ pro Ebene ueber verschachteltes Schema | Mittel bis hoch bei tiefer Verschachtelung |
Mironsoft
React-Architektur, Performance und Magento-Frontend-Integration
React-Frontends, die schnell bleiben statt mit jedem Feature langsamer zu werden?
Wir prüfen bestehende React-Anwendungen auf unnötige Re-Renders, aufgeblähte Bundles und fragile State-Verwaltung und bauen daraus ein Frontend, das performant bleibt und sich sauber an Magento oder andere Backends anbindet.
Performance-Audit
Re-Renders, Bundle-Größe und Ladezeiten systematisch messen und beheben.
State-Architektur
Context, Zustand und Server State sauber trennen statt alles in einen Topf zu werfen.
Magento-Integration
GraphQL- oder REST-Anbindung an Magento robust und typsicher aufbauen.
10. Zusammenfassung
useFieldArray mit React Hook Form: Das Wichtigste auf einen Blick
Kernproblem
Manueller Array-State rendert bei jeder Aenderung die gesamte Liste neu, useFieldArray nur bei strukturellen Aenderungen.
Stabile Keys
fields[i].id statt Array-Index als React-Key verhindert vertauschte Werte beim Entfernen und Einfuegen.
Validierung
zodResolver mit z.array(z.object(...)) validiert jeden Eintrag einzeln und die Array-Laenge insgesamt.
Umsortieren
move() und swap() arbeiten ueber die interne id-Struktur, kein manuelles Array-Splicing noetig.