# Formulare in React 19: Actions, useActionState und useOptimistic

URL: https://www.mikebild.dev/de/blog/formulare-in-react-19-mit-actions/

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:

```jsx
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:

```jsx
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:

```jsx
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:

```jsx
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.

```jsx
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:

```jsx
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:

```mermaid
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:

```mermaid
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:

```js
"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](https://www.mikebild.dev/de/blog/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

- [React 19 Release-Blogpost (Actions-Überblick)](https://react.dev/blog/2024/12/05/react-19)
- [`useActionState` – react.dev](https://react.dev/reference/react/useActionState)
- [`useFormStatus` – react.dev](https://react.dev/reference/react-dom/hooks/useFormStatus)
- [`useOptimistic` – react.dev](https://react.dev/reference/react/useOptimistic)
- [`<form action>` – react.dev](https://react.dev/reference/react-dom/components/form)
- [`"use server"` / Server Functions – react.dev](https://react.dev/reference/rsc/use-server)
- [`useTransition` (Hintergrund zu Actions/Transitions) – react.dev](https://react.dev/reference/react/useTransition)
