PAS7 Studio

Technologie

useActionState Deep Dive: Mutation-Flows, optimistisches UI, Action-Queue und wann eine Data-Layer weiterhin gewinnt (React 19+)

Ein praktischer Deep Dive zu React 19 useActionState: wie die Action-Queue funktioniert, warum Transitions für isPending entscheidend sind, Progressive Enhancement mit permalink, Integration von useOptimistic für sofortiges UX, Cancellation via AbortController und ein Entscheidungsrahmen dafür, wann TanStack Query / SWR / RTK Query weiterhin Caching, Retries und Invalidation besitzen sollten.

Studiofoto eines Notizbuchs: eine Action-Queue, Notizen zu optimistischem UI und React-19-Form-Pfeile
Guide / SerieSerienartikel

React 2026 Primitives & Compiler Upgrade Guide

Dies ist ein Kapitel im React 2026 Primitives & Compiler Upgrade Guide. Fokus: useActionState — Mutation-Flows, optimistisches UX, Queue-Semantik und Integrations-Patterns.

Was du aus diesem Deep Dive mitnimmst

Dieses Kapitel geht um Korrektheit, UX und Ownership-Grenzen: was useActionState garantiert, was nicht — und wie du es nutzt, ohne eine halbgare Data-Layer nachzubauen.

  • Ein Mental Model für Actions + useActionState (welcher State gehalten wird und wo die Arbeit läuft). [1][2]

  • Die Action-Queue: sequentielle Ausführung, warum sie bestimmte Race-Bugs verhindert und wann sie zur Performance-Falle wird. [2]

  • Warum isPending von Transitions abhängt — und zwei sichere Wege, Actions auszulösen. [2]

  • Progressive Enhancement: permalink, Server-Responses vor Abschluss der Hydration und was Frameworks typischerweise übernehmen. [2][6]

  • Optimistisches UX mit useOptimistic: sofortiges UI, Rollback und wie du stale optimistic state vermeidest. [1][3]

  • Cancellation-Patterns mit AbortController, wenn Nutzer Actions „spammen“ können. [2]

  • Ein Entscheidungsrahmen: wann TanStack Query / SWR / RTK Query weiterhin Caching, Retries, Invalidation und Synchronisation besitzen sollten. [7][8][9]

  • Nächstes Kapitel: React <Activity /> Deep Dive (State-Retention + Effects-Verhalten + Rollout-Pitfalls). [13]

Warum useActionState existiert: Mutation-Flows wieder „langweilig“ machen

Die meisten Apps haben zwei große Aufgaben: Daten anzeigen und Daten mutieren. Loading ist oft straightforward; die Komplexität steckt in Mutations — Korrektheit, UX, Debuggability und State-Maschinen.

React 19s „Actions“-Richtung standardisiert genau dieses Handshake: pending state, optimistisches UI und Fehlerbehandlung ohne ad-hoc State Machines pro Form oder Button. [1]

useActionState ist dünn, aber wichtig: es kapselt eine Action, speichert das letzte Ergebnis als State und liefert isPending, damit das UI während Async-Arbeit responsiv bleibt. Und es bringt eine zentrale Semantik mit: Dispatches werden gequeued und sequentiell ausgeführt. [2]

API-Semantik, die in Produktion wirklich zählt

Die Signatur ist reducer-ähnlich: zuerst previousState, dann Payload (bei Forms oft FormData). Der Return-Wert wird der nächste State. [2]

React queued mehrere Dispatches und führt sie sequentiell aus. Jeder Run sieht das vorherige Ergebnis als previousState. Das ist ein Korrektheits-Feature, kann aber Backpressure erzeugen, wenn Actions schneller getriggert werden als sie fertig werden. [2]

Transitions sind nicht optional. Wenn du den Dispatcher manuell aufrufst, muss das in einer Transition (startTransition) passieren. Übergibst du ihn als action / formAction, wrapped React das für dich. Sonst kann isPending falsch wirken und React warnen. [2]

Error Handling ist eine Falle: wenn die Action wirft, kann React danach gequeue-te Dispatches überspringen. Für erwartbare Fehler (Validation, 4xx) lieber einen Error-State zurückgeben statt throw. Throw bleibt für echte Ausnahmen in Error Boundaries. [2]

Progressive Enhancement ist im API angelegt: permalink ist für RSC-fähige Frameworks, damit Forms vor geladenem JS funktionieren. Mit Server Functions muss auch der Initial State serialisierbar sein. [2]

Baseline-Pattern: Form-Submit mit typisiertem Result State

Der schnellste „Immediate Win“ sind Forms: serverseitige Validation Errors, Field Errors und ein Pending State, der Submit zuverlässig deaktiviert — ohne Prop Drilling. Reacts Beispiele zielen genau darauf. [1][2][4]

Ein praktischer Ansatz: Result State als discriminated union modellieren, damit das UI nie raten muss, was drinsteht.

TSX
import { useActionState } from "react";

type FieldErrors = { email?: string; password?: string };

type SubmitState =
  | { kind: "idle" }
  | { kind: "error"; message: string; fieldErrors?: FieldErrors }
  | { kind: "success"; message: string };

async function register(
  _prev: SubmitState,
  formData: FormData,
): Promise<SubmitState> {
  const email = String(formData.get("email") ?? "").trim();
  const password = String(formData.get("password") ?? "");

  const fieldErrors: FieldErrors = {};
  if (!email.includes("@")) fieldErrors.email = "Enter a valid email.";
  if (password.length < 8) fieldErrors.password = "Use at least 8 characters.";

  if (fieldErrors.email || fieldErrors.password) {
    return { kind: "error", message: "Fix the highlighted fields.", fieldErrors };
  }

  const res = await fetch("/api/register", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ email, password }),
  });

  if (!res.ok) {
    return { kind: "error", message: "Registration failed. Try again." };
  }

  return { kind: "success", message: "Account created." };
}

export function RegisterForm() {
  const [state, action, isPending] = useActionState<SubmitState, FormData>(
    register,
    { kind: "idle" },
  );

  return (
    <form action={action} className="space-y-3">
      <div className="space-y-1">
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" />
        {state.kind === "error" && state.fieldErrors?.email ? (
          <p role="alert">{state.fieldErrors.email}</p>
        ) : null}
      </div>

      <div className="space-y-1">
        <label htmlFor="password">Password</label>
        <input id="password" name="password" type="password" />
        {state.kind === "error" && state.fieldErrors?.password ? (
          <p role="alert">{state.fieldErrors.password}</p>
        ) : null}
      </div>

      <button type="submit" disabled={isPending}>
        {isPending ? "Creating…" : "Create account"}
      </button>

      {state.kind === "error" ? <p role="alert">{state.message}</p> : null}
      {state.kind === "success" ? <p>{state.message}</p> : null}
    </form>
  );
}

Ein wichtiges Form-Detail: React-19 Form Actions können uncontrolled inputs nach Success automatisch resetten; für manuelle Resets gibt es requestFormReset. Für Draft/Save-Progress-Flows unbedingt uncontrolled vs controlled testen. [1]

Design-System-Trick: useFormStatus verhindert Prop Drilling von Pending State

Wenn du eine Component Library hast (Button, SubmitButton, FormFooter), ist isPending durchzureichen nervig. useFormStatus liest den Parent-Form-Status wie Context und ist genau dafür gedacht. [4]

TSX
import { useFormStatus } from "react-dom";

type Props = { label: string };

export function SubmitButton({ label }: Props) {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? "Submitting…" : label}
    </button>
  );
}

Optimistic UI ohne Selbstbetrug: useOptimistic + echte Rollback-Story

Optimistic UI heißt nicht „so tun als ob“. Es heißt: den erwarteten Endzustand zeigen, während die Request pending ist — und sauber recovern können, wenn sie scheitert. useOptimistic ist in React 19 als Pairing zu Actions gedacht: Optimistic State existiert während der Action und React kann danach wieder auf den Source-of-truth zurückgehen. [1][3]

Ein wichtiger Trick aus den Docs: mit useOptimistic(items, reducer) kann React den Reducer erneut ausführen, wenn sich die Basis-Prop items während einer pending Action ändert. Das reduziert stale optimistic state Bugs in kollaborativen oder quasi-realtime UIs. [3]

TSX
import { useActionState, useOptimistic } from "react";

type Todo = { id: string; title: string; pending?: true };

type AddState = { lastError: string | null };

type ApiResult =
  | { ok: true; created: { id: string; title: string } }
  | { ok: false; error: string };

async function addTodoOnServer(title: string): Promise<ApiResult> {
  const res = await fetch("/api/todos", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ title }),
  });

  if (!res.ok) return { ok: false, error: "Failed to create todo." };

  const data: unknown = await res.json();
  const created = data as { id?: unknown; title?: unknown };
  if (typeof created.id !== "string" || typeof created.title !== "string") {
    return { ok: false, error: "Bad response." };
  }

  return { ok: true, created: { id: created.id, title: created.title } };
}

function optimisticReducer(
  state: Todo[],
  action:
    | { type: "add"; todo: Todo }
    | { type: "replace"; tempId: string; real: Todo },
) {
  if (action.type === "add") return [action.todo, ...state];
  return state.map(t => (t.id === action.tempId ? action.real : t));
}

export function TodoComposer({ todos }: { todos: Todo[] }) {
  const [optimisticTodos, applyOptimistic] = useOptimistic(todos, optimisticReducer);

  const [state, submit, isPending] = useActionState<AddState, FormData>(
    async (prev, formData) => {
      const title = String(formData.get("title") ?? "").trim();
      if (!title) return { lastError: "Title is required." };

      const tempId = `temp:${Date.now()}`;
      applyOptimistic({ type: "add", todo: { id: tempId, title, pending: true } });

      const result = await addTodoOnServer(title);
      if (!result.ok) return { lastError: result.error };

      applyOptimistic({
        type: "replace",
        tempId,
        real: { id: result.created.id, title: result.created.title },
      });

      return { lastError: null };
    },
    { lastError: null },
  );

  return (
    <div className="space-y-3">
      <form action={submit} className="flex gap-2">
        <input name="title" placeholder="Add a todo…" />
        <button type="submit" disabled={isPending}>Add</button>
      </form>

      {state.lastError ? <p role="alert">{state.lastError}</p> : null}

      <ul className="space-y-1">
        {optimisticTodos.map(t => (
          <li key={t.id}>
            {t.title}{t.pending ? " (saving…)" : ""}
          </li>
        ))}
      </ul>
    </div>
  );
}

Queued Actions: wenn sequentielle Konsistenz zu Backpressure wird

Die Action-Queue ist ein unterschätztes Detail. Sequentielle Dispatches vermeiden bestimmte Race-Bugs (späte Responses überschreiben neuere Intent nicht). Gleichzeitig kann sich ein Backlog aufbauen: Click-Spam serialisiert Netzwerkcalls und macht die UI träge. React empfiehlt explizit useOptimistic, Cancellation oder ein anderes Modell für komplexere Fälle. [2][3]

Cancellation ist sinnvoll, wenn „latest intent wins“ gilt, aber nicht kostenlos: ein Request-Abort spult serverseitige Mutations nicht zurück. Sicherer ist es bei idempotenten Effekten oder wenn der Effekt gefahrlos ignoriert/erneut versucht werden kann. [2]

TSX
import { startTransition, useActionState, useRef } from "react";

type State = { value: number; error: string | null };

type Payload = { delta: number };

async function postDelta(delta: number, signal: AbortSignal): Promise<number> {
  const res = await fetch("/api/counter", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ delta }),
    signal,
  });

  if (!res.ok) throw new Error("Request failed");

  const data: unknown = await res.json();
  const next = (data as { value?: unknown }).value;
  if (typeof next !== "number") throw new Error("Bad response");

  return next;
}

export function Stepper() {
  const abortRef = useRef<AbortController | null>(null);

  const [state, dispatch, isPending] = useActionState<State, Payload>(
    async (prev, payload) => {
      abortRef.current?.abort();
      const controller = new AbortController();
      abortRef.current = controller;

      try {
        const nextValue = await postDelta(payload.delta, controller.signal);
        return { value: nextValue, error: null };
      } catch (e) {
        const message = e instanceof Error ? e.message : "Unknown error";
        return { value: prev.value, error: message };
      }
    },
    { value: 0, error: null },
  );

  function bump(delta: number) {
    startTransition(() => dispatch({ delta }));
  }

  return (
    <div className="space-y-2">
      <div>Value: {state.value}{isPending ? " (updating…)" : ""}</div>
      <div className="flex gap-2">
        <button onClick={() => bump(-1)}>-</button>
        <button onClick={() => bump(1)}>+</button>
      </div>
      {state.error ? <p role="alert">{state.error}</p> : null}
    </div>
  );
}

React docs: AbortController kann pending Actions abbrechen, aber ein Request-Abort macht serverseitige Mutations nicht rückgängig. [2]

Screenshot des Abschnitts queued-actions-and-cancellation

Wann useActionState Boilerplate reduziert — und wann eine Data-Layer weiterhin das Problem besitzt

Die saubere Grenze: useActionState ist ideal für UI-owned Mutation-Handshakes; Data Layers sind stark bei systemweiter Datenkorrektheit (Cache, Retries, Invalidation, Cross-Screen-Sync).

React 19 liefert ein besseres Mutation-Primitive, ersetzt aber bewusst nicht die Verantwortlichkeiten von Data Libraries. Du musst weiterhin entscheiden, wer Cache und Synchronisation besitzt. [1][2]

Eine einfache Entscheidungstabelle (Heuristik, keine Dogma):

MD
| Bedarf | useActionState | Data Layer |
|---|---|---|
| Pending + Field/Server Errors in Forms | ✅ | ✅ |
| Einfache One-off Mutation mit lokalem UI Update | ✅ | ✅ |
| Caching + Dedupe + Background Refetch | ❌ | ✅ (SWR / TanStack Query) |
| Retry / Backoff Policies | ❌ | ✅ |
| Cache Invalidation über viele Screens | ❌ | ✅ |
| Offline Queues / Persistence | ❌ | ✅ |
| Optimistic UI im kleinen Scope | ✅ (mit useOptimistic) | ✅ |
| Optimistic UI + Shared-Cache-Reconciliation | ⚠️ | ✅ |

Wenn eine Mutation mehrere Queries oder Screens betrifft, sollte oft die Library, die den Graph modelliert, Invalidation und Refetch besitzen. TanStack Query stellt Invalidation als First-Class Workflow dar; RTK Query nutzt Tags; SWR bietet Mutation + Revalidation aus ähnlichen Gründen. [7][9][8]

Wo useActionState glänzt

UI-owned Mutation-Flows: Forms, Settings Panels, Wizards, “Save”-Buttons und Komponenten, wo Result State nur lokal gebraucht wird (plus evtl. Redirect). Weniger Boilerplate, bessere UX-Defaults. [1][2]

Wo eine Data-Layer gewinnt

Systemweite Datenkorrektheit: Shared Caches, Refetch-Orchestrierung, Retries, Dedupe, Offline, Cross-Route-Konsistenz. Das sind nicht-triviale Verantwortlichkeiten, die useActionState nicht modelliert. [7][8][9]

Typischer Hybrid

useActionState für das UI-Handshake (pending + errors), aber Invalidation/Refetch an die Data-Layer (oder Next Cache Model) delegieren. Saubere Grenzen. [6][7]

Integrations-Pattern: useActionState als UI Shell, TanStack Query als Cache Engine

Ein praktischer Hybrid: useActionState für Form-level Success/Error, TanStack Query für Invalidation, damit andere Screens die Mutation reflektieren. Die TanStack Docs zeigen invalidateQueries als Standard-Schritt und erklären, dass ein Promise in onSuccess die Mutation pending hält, bis die Invalidation fertig ist. [7]

TSX
import { useActionState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";

type State = { ok: boolean; message: string };

type Payload = { title: string };

declare function addTodo(input: Payload): Promise<void>;

export function AddTodo() {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: addTodo,
    onSuccess: async () => {
      await queryClient.invalidateQueries({ queryKey: ["todos"] });
    },
  });

  const [state, dispatch, isPending] = useActionState<State, FormData>(
    async (_prev, formData) => {
      const title = String(formData.get("title") ?? "").trim();
      if (!title) return { ok: false, message: "Title is required." };

      try {
        await mutation.mutateAsync({ title });
        return { ok: true, message: "Saved." };
      } catch {
        return { ok: false, message: "Failed to save." };
      }
    },
    { ok: true, message: "" },
  );

  return (
    <form action={dispatch} className="flex gap-2">
      <input name="title" placeholder="New todo" />
      <button type="submit" disabled={isPending}>
        {isPending ? "Saving…" : "Save"}
      </button>
      {state.message ? <span>{state.message}</span> : null}
    </form>
  );
}

TanStack Query: Invalidation nach Mutations via onSuccess + invalidateQueries (Promise.all für mehrere Keys). [7]

Screenshot des Abschnitts integration-example-react-query

Performance- und Korrektheits-Notizen (die Dinge, die später beißen)

Die meisten Regressionen kommen von vermischten Verantwortlichkeiten oder ignorierter Semantik: Transitions, Queueing, Resets und State-Größe.

Praktische Heuristik: speichere im Action State nur, was die UI nach der Mutation braucht (Errors, Success, IDs). Durable/shared Data gehören auf den Server, in Framework-Cache oder in die Data-Layer. [1][6][7]

Transition-Disziplin

nicht verhandelbar

Manueller Dispatch muss in startTransition laufen, sonst warnt React und isPending verhält sich nicht wie gedacht. action/formAction wrapped für dich. [2]

Queue-Backpressure

Spam-Pfade beachten

Sequentieller Dispatch vermeidet gewisse Races, kann aber Interaktionen in ein Backlog serialisieren. Für Rapid-fire: useOptimistic, Cancellation oder anderes Modell. [2][3]

State-Payload-Größe

klein halten

Action State ist UI State (Errors, Messages, IDs), kein Cache. Große Objekte erhöhen Render-Kosten und erschweren Reconciliation.

Form-Reset-Verhalten

uncontrolled testen

React 19 Form Actions können uncontrolled inputs nach Success resetten; requestFormReset existiert für manuelle Resets. Draft/Progress-Flows brauchen bewusste Entscheidungen. [1]

Eine Rollout-Checkliste, die du einem Team geben kannst

Wenn du useActionState in der Codebase ausrollst, verhindern diese Checks die häufigsten Korrektheits- und UX-Fallen.

  • Action-Result State als discriminated union modellieren (keine ambigen „maybe string, maybe object“-States).

  • Für erwartbare Fehler Error-State zurückgeben statt throw, damit gequeue-te Actions nicht übersprungen werden. [2]

  • Manueller Dispatch muss in startTransition laufen — sonst ist isPending irreführend. [2]

  • Für spammy Interactions entscheiden: sequentielle Konsistenz, Cancellation (AbortController) oder optimistic reducer. [2][3]

  • Wenn die Mutation mehrere Screens betrifft: Invalidation/Refetch an Data-Layer (oder Next Cache Model) delegieren. [7][9][6]

  • Bei Server Functions: Initial State serialisierbar machen und Progressive Enhancement testen (permalink / Pre-Hydration Submits). [2][6]

  • Uncontrolled Form-Reset testen und entscheiden, ob controlled inputs oder expliziter Reset nötig sind. [1]

FAQ

Ersetzt useActionState TanStack Query / SWR / RTK Query?

Nein. useActionState ist stark für das UI-Handshake (pending + result + errors). Data Layers besitzen weiterhin Caching, Retries, Invalidation, Background Refetch und Cross-Screen-Synchronisation. Wenn eine Mutation mehrere Queries/Routes betrifft, ist eine Data Layer (oder ein Framework-Cache-Modell wie Next.js) meist der richtige Owner. [7][8][9][6]

Warum aktualisiert sich `isPending` nicht wie erwartet?

Wenn du den Dispatcher manuell aufrufst, muss das in einer Transition (`startTransition`) passieren. Alternativ übergib ihn als `action`/`formAction` — dann wrapped React automatisch. Sonst kann React warnen und `isPending` wirkt falsch. [2]

Sind Actions wirklich sequentiell? Was bringt mir das?

Ja: React queued Dispatches und führt sie sequentiell aus, sodass jeder Run das vorherige Ergebnis als `previousState` sieht. Das verhindert bestimmte Out-of-order Bugs, kann aber Backpressure in spammy Pfaden erzeugen. [2]

Wie vermeide ich Queue-Backlog bei schnellen Interaktionen?

Nutze `useOptimistic` für sofortiges UI, cancelle pending Arbeit mit `AbortController` für „latest intent wins“, oder wähle ein anderes Modell, wenn useActionState nicht passt. React nennt diese Escape Hatches explizit. [2][3]

Was ist der sichere Weg für Error Handling in reducerAction?

Für erwartbare Fehler (Validation, 4xx) gib einen Error-State zurück statt zu werfen. Wenn die Action wirft, kann React danach gequeue-te Dispatches überspringen. Throw bleibt für echte Ausnahmen in Error Boundaries. [2]

Was ist permalink und brauche ich das?

`permalink` ist für RSC-fähige Frameworks mit Progressive Enhancement gedacht. Wenn eine Form submitet, bevor JS geladen ist, kann der Browser zum permalink Route navigieren statt vom aktuellen URL abhängig zu sein. Frameworks übernehmen das oft, aber es zählt, wenn du „funktioniert vor JS“ willst. [2][6]

Warum wurden meine Form-Felder nach einem erfolgreichen Submit geleert?

React 19 Form Actions können uncontrolled inputs nach Success automatisch resetten. Außerdem gibt es `requestFormReset` für manuelle Resets. Für Draft/Save-Progress-Flows solltest du uncontrolled vs controlled bewusst wählen und testen. [1]

Quellen

Wir listen nur Quellen, die Semantik, Caveats und Integrations-Aussagen in diesem Kapitel direkt stützen.

Willst du Actions ohne Regressionen einführen?

useActionState ist stark, wenn die Grenze sauber bleibt: die UI besitzt das Mutation-Handshake, das System besitzt Shared-Data-Korrektheit.

Wenn du auf React 19 upgradest (oder Server Actions, optimistisches UX und eine Cache-Strategie einführst), kann PAS7 Studio deine Mutation-Flows auditieren, Ownership-Grenzen definieren und einen inkrementellen Rollout mit Guardrails umsetzen.

Sie sind hier02/04

useActionState Deep Dive: Mutation-Flows, optimistisches UI und Integrations-Patterns

Verwandte Artikel

growthFebruary 15, 2026

AI SEO / GEO im Jahr 2026: Ihre nächsten Kunden sind nicht Menschen — sondern Agents

Suche verschiebt sich von Klicks zu Antworten. Bots und AI-Agents crawlen, zitieren, empfehlen — und kaufen zunehmend. Erfahren Sie, was AI SEO / GEO bedeutet, warum klassisches SEO nicht mehr reicht und wie PAS7 Studio Marken im agentischen Web sichtbar macht.

Lesen →
telegram-media-saverJanuary 8, 2025

Automatisches Tagging und Suche für gespeicherte Links

Integration mit GDrive/S3/Notion für automatisches Tagging und schnelle Suche über Such-APIs

Lesen →
servicesJanuary 2, 2025

Bot-Entwicklung und Automatisierungs-Dienste

Professionelle Telegram-Bot-Entwicklung und Automatisierung von Geschäftsprozessen: Chatbots, KI-Assistenten, CRM-Integrationen und Prozessautomatisierung.

Lesen →
backend-engineeringFebruary 15, 2026

Bun vs Node.js im Jahr 2026: Warum sich Bun schneller anfühlt (und wie du dein Projekt vor der Migration prüfst)

Bun ist ein schnelleres All-in-one JavaScript-Toolkit: Runtime, Package Manager, Bundler und Test Runner. Hier ist, was wirklich stimmt (mit Benchmarks), was brechen kann und wie du mit @pas7-studio/bun-ready einen kostenlosen Readiness-Audit bekommst.

Lesen →

Professionelle Entwicklung für Ihr Geschäft

Wir erstellen moderne Web-Lösungen und Bots für Unternehmen. Erfahren Sie, wie wir Ihnen helfen können, Ihre Ziele zu erreichen.