useFieldArray: Dynamische Formularfelder mit React Hook Form
AI generated
{ }
React 19 · React Hook Form · Formulare
useFieldArray in der Praxis
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.

13 Min. Lesezeit useFieldArray · React Hook Form Zod Validierung

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.

11. FAQ: useFieldArray mit React Hook Form: Das Wichtigste auf einen Blick

1Was macht useFieldArray anders als ein Array in useState?
useFieldArray nutzt die uncontrolled Ref-Architektur von React Hook Form und rendert die Liste nur bei strukturellen Aenderungen wie Hinzufuegen oder Entfernen neu. Ein Array in useState ersetzt bei jeder Feldaenderung das komplette Array und rendert dadurch immer die gesamte Liste neu.
2Warum darf ich nicht den Array-Index als React-Key verwenden?
Beim Entfernen oder Einfuegen mitten in der Liste verschieben sich die Indizes aller nachfolgenden Eintraege. React ordnet dann bestehende DOM-Nodes und deren unkontrollierte Eingabewerte falschen logischen Eintraegen zu. Die von React Hook Form generierte, stabile id in fields[i].id verhindert dieses Problem.
3Wie fuege ich vorbefuellte Eintraege bereits beim ersten Render hinzu?
Ueber defaultValues im umschliessenden useForm-Aufruf, zum Beispiel defaultValues: { addresses: [{ street: '', city: '' }] }. useFieldArray liest diese initiale Struktur aus und zeigt sie sofort an, bevor der Nutzer append oder remove aufruft.
4Wie validiere ich einzelne Eintraege einer Liste?
Mit einem Schema-Resolver wie zodResolver und einem Zod-Schema der Form z.array(z.object({...})). Fehler einzelner Felder landen dann strukturell passend unter errors.fieldName[index].feldname und lassen sich direkt bei der jeweiligen Zeile anzeigen.
5Kann ich auch pruefen, ob die Liste mindestens einen Eintrag enthaelt?
Ja, mit .min(1) direkt auf dem Array-Schema selbst, zum Beispiel z.array(z.object({...})).min(1). Dieser Array-Level-Fehler ist von den Feld-Fehlern der einzelnen Zeilen getrennt und muss separat angezeigt werden, meist oberhalb der Liste.
6Wie sortiere ich Eintraege per Drag and Drop um?
Im onDragEnd-Callback der Drag-and-Drop-Bibliothek die neue Zielposition ermitteln und dort move(oldIndex, newIndex) aus useFieldArray aufrufen. React Hook Form uebernimmt danach die korrekte Neuordnung der internen Feld-Referenzen automatisch.
7Wie funktionieren verschachtelte Field Arrays, zum Beispiel Rabatte pro Rechnungsposition?
Ueber zwei gekoppelte useFieldArray-Aufrufe, einen aeusseren fuer die Rechnungspositionen und einen inneren pro Zeile fuer die Rabatte dieser Position. Der Pfad des inneren Arrays wird dynamisch aus dem aeusseren Index gebildet, am besten gekapselt in einer eigenen Unterkomponente pro Zeile.
8Warum bleibt useFieldArray nach einem asynchronen API-Ladevorgang leer?
Weil defaultValues bereits beim ersten Render fixiert werden. Werden die tatsaechlichen Werte erst spaeter asynchron nachgeladen, muss anschliessend reset() mit den neuen Werten aufgerufen werden, sonst bleibt useFieldArray auf dem urspruenglich leeren Zustand stehen.
9Was bewirkt useWatch im Vergleich zu einem globalen watch()?
useWatch mit einem gezielten name beobachtet nur genau diese eine Eigenschaft und loest ein Re-Render nur in der Komponente aus, die useWatch aufruft. Ein globales watch() ohne name beobachtet das gesamte Formular und rendert bei jeder Aenderung irgendeines Feldes neu.
10Was ist shouldUnregister und warum ist es bei bedingten Zeilen wichtig?
shouldUnregister bestimmt, ob der Wert eines Feldes aus dem Formular-State entfernt wird, sobald es aus dem DOM verschwindet. Bei bedingt gerenderten Zeilen kann fehlendes Verstaendnis dieser Option dazu fuehren, dass Werte laengst entfernter Felder ungewollt im Formular-State erhalten bleiben und spaeter mit uebermittelt werden.