Blog

Formulare in React 19: Actions, useActionState und useOptimistic

React 19 macht das Formular zur eigentlichen Schnittstelle: eine Funktion als form action, dazu drei Hooks für Ergebnis, Pending-Zustand und sofortiges UI-Feedback. Dieser Beitrag zeigt das Zusammenspiel – und wo optimistisches Rendern ohne sauberen Fehlerpfad kippt.

≈ 9 Min. Lesezeit

Diesen Beitrag anhören (13 Min.)

MP3 herunterladen

Ein optimistisch eingefügter Eintrag, der bei einem Fehler wieder verschwindet und keine Meldung hinterlässt, ist für den Nutzer eine Falschaussage: Er hat abgeschickt, er hat es kurz gesehen, dann war da nichts mehr. Das ist die eine Stelle, an der die Formular-APIs von React 19 Arbeit machen statt sie abzunehmen – und deshalb steht sie schon hier am Anfang.

Alles andere daran ist Entlastung. Bis React 18 bestand ein Formular, das einen Namen speichert, aus onSubmit, event.preventDefault(), einem useState für den Ladezustand, einem zweiten für die Fehlermeldung und irgendwo einem try/catch – erstaunlich viel Handarbeit für eine erstaunlich alltägliche Aufgabe. React 19, stabil seit dem 5. Dezember 2024, übergibt dem <form>-Element stattdessen direkt eine Funktion und stellt drei Hooks daneben: useActionState, useFormStatus und useOptimistic. Sie kümmern sich um genau die Dinge, die man vorher von Hand zusammenbauen musste. Ich gehe der Reihe nach durch, was jeder dieser Bausteine übernimmt, wie sie zusammenspielen und wo der Fehlerpfad aus dem ersten Absatz hingehört.

Von onSubmit zu action

Der Ausgangspunkt ist eine kleine, aber folgenreiche Änderung am <form>-Element. In React 19 akzeptiert das action-Attribut eine Funktion – nicht mehr nur einen URL-String wie im klassischen HTML. Diese Funktion bekommt beim Absenden ein FormData-Objekt und wird von React aufgerufen. React ruft dabei selbst preventDefault auf, verpackt den Aufruf in eine Transition und – ein Detail, das gerne übersehen wird – setzt ein unkontrolliertes Formular nach erfolgreicher Action automatisch zurück.

So sieht das im Vergleich aus. Vorher:

function NameForm() {
  const [isSaving, setIsSaving] = useState(false);
  const [error, setError] = useState(null);

  async function handleSubmit(event) {
    event.preventDefault();
    setIsSaving(true);
    setError(null);
    const formData = new FormData(event.currentTarget);
    const result = await save(formData.get("name"));
    setIsSaving(false);
    if (result.error) setError(result.error);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" />
      <button disabled={isSaving}>{isSaving ? "Saving…" : "Save"}</button>
      {error && <p>{error}</p>}
    </form>
  );
}

Und nachher, mit einer Client Action:

function NameForm() {
  async function updateName(formData) {
    await save(formData.get("name"));
  }

  return (
    <form action={updateName}>
      <input name="name" />
      <button>Save</button>
    </form>
  );
}

Die zweite Variante ist nicht nur kürzer, sie verlagert die Kontrolle. React weiß jetzt, dass hier eine Übergabe stattfindet, hält den Pending-Zustand konsistent und kümmert sich um das native Submit-Verhalten. Eine Funktion, die auf diese Weise Übergänge auslöst, nennt React in der Dokumentation schlicht eine „Action“. Was in der zweiten Variante noch fehlt, ist alles, was den ersten Codeblock eigentlich ausgemacht hat: der Ladezustand und die Fehlerbehandlung. Genau die holen die drei Hooks zurück – aber ohne die Handarbeit.

Wer übernimmt was

Die drei Hooks klingen auf den ersten Blick ähnlich, haben aber sehr unterschiedliche Aufgaben. Deshalb vor dem Code die Zuständigkeiten:

  • useActionState bündelt den kompletten Lebenszyklus einer Action an einer Stelle: Es umschließt deine Action-Funktion, hält das zurückgegebene Ergebnis als State, liefert ein isPending-Flag und reicht dir eine umschlossene formAction zurück, die du ans Formular hängst.
  • useFormStatus liest den Pending-Zustand des nächstgelegenen umgebenden <form> – gedacht für Kindkomponenten wie einen Submit-Button, die nichts über die Action selbst wissen müssen, aber wissen wollen, ob gerade gesendet wird.
  • useOptimistic zeigt sofort einen erwarteten Zwischenzustand an, während die Action noch läuft, und konvergiert danach automatisch auf den realen State zurück – bei Erfolg auf den Serverwert, bei Fehler auf den alten Wert.

Man kann die drei einzeln oder zusammen einsetzen. In einem echten Formular greifen sie meistens ineinander, und das Zusammenspiel ist genau der Punkt, an dem es interessant wird.

useActionState: Ergebnis und Pending an einer Stelle

useActionState importiert man aus react – nicht aus react-dom, was eine häufige erste Fehlerquelle ist. Die Signatur:

const [state, formAction, isPending] = useActionState(action, initialState, permalink);

Der entscheidende und für viele überraschende Punkt steckt in der Signatur der übergebenen Action-Funktion. Sie bekommt nicht nur die FormData, sondern als erstes Argument den vorherigen State und erst als Zweites die Formulardaten:

import { useActionState } from "react";

async function updateName(previousState, formData) {
  const name = formData.get("name");
  const error = await save(name);
  if (error) {
    return { error };
  }
  return { ok: true };
}

function NameForm() {
  const [state, formAction, isPending] = useActionState(updateName, {});

  return (
    <form action={formAction}>
      <input name="name" disabled={isPending} />
      <SubmitButton />
      {state.error && <p role="alert">{state.error}</p>}
      {state.ok && <p>Saved.</p>}
    </form>
  );
}

Was hier passiert, ist der ganze erste Codeblock von oben – nur ohne die Handarbeit. Der Rückgabewert der Action wird zum neuen state, das isPending-Flag verwaltet React, und formAction ist die umschlossene Funktion, die ans action-Attribut gehängt wird. Die Fehlermeldung ist kein separates useState mehr, sondern fällt einfach aus dem zurückgegebenen State heraus.

Wenn ich in einem Code-Review sehe, dass der State plötzlich undefined ist oder der vorherige Zustand verloren geht, ist die Ursache fast immer dieselbe: Die Action wurde als (formData) => … geschrieben statt als (previousState, formData) => …. Der erste Parameter ist eben nicht die FormData. Wer aus der alten onSubmit-Welt kommt, hat diesen Reflex im Finger, und er kostet regelmäßig ein paar Minuten Suchen.

Ein historischer Hinweis, damit ältere Tutorials nicht verwirren: Dieser Hook hieß in den Canary-Versionen useFormState und steckte in react-dom. Mit React 19 wurde er nach react verschoben und in useActionState umbenannt; das dritte Element isPending kam dabei hinzu. useFormState gilt seither als deprecated – wenn du es noch irgendwo siehst, ist der Code auf einem älteren Stand.

useFormStatus: Pending im Kind lesen

Jetzt zum Submit-Button, den ich oben schon als <SubmitButton /> eingesetzt habe. Man könnte fragen, warum der ausgelagert ist – schließlich hätte ich isPending aus useActionState auch direkt an einen Button im Formular geben können. Beides ist möglich, aber useFormStatus löst ein spezielles Problem: eine wiederverwendbare Komponente, die den Pending-Zustand kennt, ohne dass man ihn ihr durchreichen muss.

import { useFormStatus } from "react-dom";

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button disabled={pending}>
      {pending ? "Saving…" : "Save"}
    </button>
  );
}

Der Hook kommt aus react-dom, nimmt keine Parameter und liefert neben pending noch data, method und action des umgebenden Formulars. Die eine Regel, die man kennen muss und die für die meisten Fehlermeldungen an dieser Stelle verantwortlich ist: useFormStatus liest den Status des nächstgelegenen Eltern-<form> und muss deshalb in einer Kindkomponente stehen, nicht in derselben Komponente, die das <form> rendert.

Steht der Hook versehentlich in der Formularkomponente selbst, ist pending schlicht immer false – es gibt aus dieser Perspektive kein umgebendes Formular, dessen Status man lesen könnte. Der Button muss ein Kind sein. Die eigene SubmitButton-Komponente ist deshalb keine Geschmacksfrage: Ohne diese Verschachtelung sieht der Hook überhaupt nichts.

useOptimistic: den erwarteten Zustand sofort zeigen

Der dritte Hook ist der beeindruckendste – und der gefährlichste, wenn man ihn halb versteht. useOptimistic zeigt den Zustand, den man nach erfolgreicher Action erwartet, sofort an, ohne auf die Serverantwort zu warten. Das klassische Beispiel ist eine Nachrichtenliste, in der die eigene Nachricht sofort erscheint:

import { useOptimistic, useActionState } from "react";

function Thread({ messages, sendMessage }) {
  const [optimisticMessages, addOptimistic] = useOptimistic(
    messages,
    (currentMessages, newText) => [
      ...currentMessages,
      { text: newText, sending: true },
    ]
  );

  const [state, formAction] = useActionState(
    async (previousState, formData) => {
      const text = formData.get("message");
      addOptimistic(text);
      const error = await sendMessage(text);
      if (error) {
        return { error };
      }
      return { ok: true };
    },
    {}
  );

  return (
    <div>
      <ul>
        {optimisticMessages.map((message, index) => (
          <li key={index}>
            {message.text}
            {message.sending && <span> (sending…)</span>}
          </li>
        ))}
      </ul>
      <form action={formAction}>
        <input name="message" />
        <SubmitButton />
        {state.error && <p role="alert">{state.error}</p>}
      </form>
    </div>
  );
}

useOptimistic bekommt den realen State und eine reine Reducer-Funktion, die aus aktuellem State und optimistischem Wert den nächsten Zwischenzustand berechnet. Der Aufruf von addOptimistic muss innerhalb einer Action beziehungsweise Transition passieren – hier also innerhalb der async-Action, die useActionState verwaltet. Ruft man ihn außerhalb auf, gibt es eine Warnung und kein optimistisches Rendering.

Das eigentlich Elegante ist die automatische Konvergenz: Der optimistische Wert gilt nur, solange die Action läuft. Sobald sie abgeschlossen ist, fällt der angezeigte State auf den realen messages-State zurück. Bei Erfolg ist das der aktualisierte Serverwert, bei Fehler der alte Wert – die optimistisch eingefügte Nachricht verschwindet also von selbst wieder. Man muss nichts manuell zurücksetzen.

Der Fallstrick: optimistisch ohne Fehlerpfad

Die automatische Konvergenz verführt dazu, den Fehlerfall für erledigt zu halten. Erledigt ist er nicht – hier entstehen die Bugs, die im Test niemand sieht und im Support jeder meldet.

Stell dir vor, sendMessage schlägt fehl. Der optimistische Wert wird automatisch zurückgenommen – die Nachricht verschwindet aus der Liste. Aus Sicht des Nutzers heißt das: Er hat etwas abgeschickt, es erschien kurz, und dann war es weg. Ohne weitere Rückmeldung. Kein Nutzer interpretiert das als „Fehler beim Senden“; die meisten denken, sie hätten sich verklickt, und probieren es einfach nochmal – im schlimmsten Fall entsteht so ein Duplikat, sobald der Server doch geantwortet hat.

Der Denkfehler ist, useOptimistic als Fehlerbehandlung misszuverstehen. Der Hook kümmert sich um den sichtbaren Zwischenzustand, nicht um die Kommunikation des Ergebnisses. Die beiden Aufgaben muss man trennen:

  • useOptimistic sorgt dafür, dass der optimistische Wert bei einem Fehler automatisch zurückgenommen wird – das ist der Rollback des UI-Zustands, und den bekommt man geschenkt.
  • Die sichtbare Fehlermeldung ist eine eigene Verantwortung. Sie muss aus dem state von useActionState kommen: Die Action fängt den Fehler, gibt ihn als { error } zurück, und die UI leitet daraus eine Meldung ab.

Bei konkurrierenden Änderungen reicht dieser UI-Rollback nicht. Doppelte Übermittlungen, veraltete Serverstände und Konflikte brauchen weiterhin Idempotenzschlüssel, Versionsprüfung oder eine fachliche Konfliktregel auf dem Server.

Im Code oben passiert genau das: Die Action gibt im Fehlerfall { error } zurück, und {state.error && <p role="alert">…</p>} macht ihn sichtbar. Der optimistische Wert verschwindet automatisch, aber der Nutzer sieht warum. Wer den Fehler dagegen im catch verschluckt oder gar nicht erst zurückgibt, hat ein UI, das bei Fehlern stumm den falschen Eindruck erweckt, es sei nichts passiert. Optimistisches Rendern ohne echten Fehlerpfad ist schlimmer als gar kein optimistisches Rendern.

Das Zusammenspiel im Bild

Wenn man die vier Beteiligten – das Formular, die Action, den Pending-Zustand und den optimistischen Wert – zeitlich anordnet, wird das Muster deutlich:

flowchart LR
  A["form action(fn)<br/>Submit ausgelöst"] --> B["isPending = true<br/>Button disabled<br/>via useFormStatus"]
  B --> C["addOptimistic()<br/>erwarteter Zustand<br/>sofort sichtbar"]
  C --> D["await save()"]
  D -->|Erfolg| E["realer State<br/>state.ok<br/>Formular-Reset"]
  D -->|Fehler| F["optimistisch revertiert<br/>state.error sichtbar"]

Der obere Pfad ist der Normalfall, der untere der, den man aktiv gestalten muss. React nimmt einem den Weg von A bis D fast vollständig ab; die bewusste Design-Entscheidung liegt in der Verzweigung bei D – und darin, dass der Fehlerzweig nicht ins Leere läuft.

Das zweite Diagramm ordnet noch einmal die Zuständigkeiten, weil die Verwechslungsgefahr zwischen den dreien real ist:

flowchart TB
  subgraph Form["React 19 Formular"]
    UAS["useActionState<br/>Aufruf + Ergebnis + Pending"]
    UFS["useFormStatus<br/>liest Pending des Eltern-Forms<br/>im Kind"]
    UO["useOptimistic<br/>sofortiger Zwischenzustand<br/>mit Auto-Konvergenz"]
  end
  UAS -->|reicht isPending<br/>und state| UI["Fehler- und Erfolgs-Anzeige"]
  UFS -->|pending| Button["SubmitButton"]
  UO -->|optimisticState| Liste["Live-Liste"]

Ein Wort zu Server Functions

Bisher habe ich von Client Actions gesprochen – Funktionen, die im Browser laufen. Der gleiche Mechanismus trägt aber auch über die Netzwerkgrenze. Mit der Direktive "use server" am Anfang einer Datei oder einer Funktion markiert man Server Functions, die vom Client aus aufrufbar sind, aber auf dem Server ausgeführt werden:

"use server";

export async function updateName(previousState, formData) {
  const name = formData.get("name");
  await db.users.update({ name });
  return { ok: true };
}

Diese Funktion lässt sich exakt wie eine Client Action an useActionState oder direkt an <form action={…}> übergeben – die Signatur ist dieselbe. Wichtig ist, "use server" nicht mit "use client" zu verwechseln; die beiden Direktiven markieren gegensätzliche Ausführungsorte. Und Server Functions setzen ein Framework beziehungsweise einen Bundler voraus, der React Server Components unterstützt; in einer reinen Client-App gibt es sie nicht.

Ein hübscher Nebeneffekt in diesem Kontext ist Progressive Enhancement. Übergibt man useActionState als drittes Argument ein permalink – eine URL –, funktioniert das Formular auch dann, wenn JavaScript noch nicht hydriert oder gar nicht verfügbar ist: Der Browser navigiert klassisch zu dieser URL, der Server verarbeitet die FormData, und sobald React hydriert ist, übernimmt die clientseitige Action nahtlos. Das ist nur im RSC-Kontext relevant, aber es zeigt, dass das Actions-Modell bis in die Server-Kommunikation hinein durchdacht wurde und nicht bloß eine hübschere Schreibweise für onSubmit ist. Wer tiefer in dieses Zusammenspiel einsteigen will, findet in meinem Beitrag zu React Server Components und Server Actions den größeren Rahmen.

Was bleibt hängen

Unterm Strich läuft das auf einen Perspektivwechsel hinaus. Dem Formular gibt man in React 19 eine Funktion; das Hinterherräumen mit Event-Handlern entfällt. Die drei Hooks sind keine drei neuen Dinge zum Auswendiglernen, sondern die drei Verantwortungen, die man vorher selbst miteinander verknüpfen musste, sauber getrennt: useActionState für Aufruf, Ergebnis und Pending, useFormStatus für den Pending-Blick aus dem Kind, useOptimistic für das sofortige Feedback mit automatischem Rollback.

Die zwei Stolperstellen, die erfahrungsgemäß am meisten Zeit kosten: Die Action von useActionState bekommt den vorherigen State als erstes Argument, nicht die FormData. Und optimistisches Rendern ist erst dann fertig, wenn der Fehlerfall eine sichtbare Meldung hat – der Hook nimmt dir den Rollback ab, aber nicht die Kommunikation. Mit diesen beiden Punkten im Hinterkopf werden Formulare in React 19 kürzer und robuster als alles, was man vorher von Hand gebaut hat.

Weiterführende Quellen

Wie fandest du diesen Beitrag?

Kommentare