post

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.

Wenn ich in einer Schulung frage, wer schon einmal ein Formular in React gebaut hat, gehen alle Hände hoch. Wenn ich danach frage, wie oft dabei onSubmit, event.preventDefault(), ein useState für den Ladezustand, ein zweites für die Fehlermeldung und irgendwo ein try/catch im Spiel waren, wird gelacht – weil das genau die Boilerplate ist, die jeder schon dutzendfach getippt hat. Ein Formular, das einen Namen speichert, war jahrelang erstaunlich viel Handarbeit für eine erstaunlich alltägliche Aufgabe.

Mit React 19, das seit dem 5. Dezember 2024 stabil ist, hat sich an dieser Stelle etwas Grundlegendes verschoben. Das Formular ist nicht mehr ein DOM-Element, das man mit einem Event-Handler bändigt, sondern eine Schnittstelle, der man direkt eine Funktion übergibt. Drei neue Hooks – useActionState, useFormStatus und useOptimistic – nehmen einem genau die drei Dinge ab, die man vorher von Hand verdrahtet hat: das Ergebnis, den Pending-Zustand und das sofortige Feedback. In diesem Beitrag gehe ich der Reihe nach durch, was jeder dieser Bausteine übernimmt, wie sie zusammenspielen und an welcher Stelle Teilnehmer regelmäßig stolpern.

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

Bevor ich in den Code gehe, sortiere ich die Zuständigkeiten gern einmal explizit, weil die drei Hooks auf den ersten Blick ähnlich klingen, aber sehr unterschiedliche Aufgaben haben:

  • 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 Schulungen sehe, dass jemandes 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. Deshalb die eigene SubmitButton-Komponente; sie ist kein Stilmittel, sondern die Voraussetzung dafür, dass der Hook überhaupt etwas sieht.

useOptimistic: den erwarteten Zustand sofort zeigen

Der dritte Hook ist der, bei dem in Schulungen am meisten „Aha" passiert – und der am gefährlichsten ist, 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

Und genau hier ist die Stelle, an der ich in Schulungen am deutlichsten warne. Die automatische Konvergenz verführt dazu, den Fehlerfall für erledigt zu halten. Ist er aber nicht.

Stell dir vor, sendMessage schlägt fehl. Der optimistische Wert revertiert automatisch – 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.

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 nicht bloß Syntaxzucker über onSubmit ist, sondern bis in die Server-Kommunikation durchdacht wurde. Wer tiefer in dieses Zusammenspiel einsteigen will, findet in meinem Beitrag zu React Server Components und Server Actions den größeren Rahmen; wie sich das Ganze in die Gesamtarchitektur einordnet, habe ich im React-Überblick beschrieben.

Was bleibt hängen

Wenn ich am Ende einer Schulungseinheit zu diesem Thema zusammenfasse, läuft es auf einen Perspektivwechsel hinaus. Das Formular ist in React 19 nicht mehr ein DOM-Element, dem man mit Event-Handlern hinterherräumt, sondern eine Schnittstelle, der man eine Funktion gibt. Die drei Hooks sind keine drei neuen Dinge zum Auswendiglernen, sondern die drei Verantwortungen, die man vorher von Hand verdrahtet hat, 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 Dinge, die man sich merken sollte, weil sie am häufigsten 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. Wer diese beiden Punkte verinnerlicht, schreibt Formulare in React 19, die kürzer und robuster sind als alles, was man vorher von Hand gebaut hat.

Weiterführende Quellen

Kommentare

Kommentar schreiben