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.

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.
Alle Artikel in diesem Guide
01
Overview: React 2026 primitives and compiler-era mental model
Big-Picture-Architektur, was sich geändert hat und wo jedes Primitive hinpasst.
02
useActionState Deep Dive: Mutation-Flows, optimistisches UI und Integrations-Patterns
Praktischer Deep Dive zu Action-Queue, Transitions, optimistischem UX und der Grenze, wo eine Data-Layer weiterhin gewinnt.
03
React <Activity />: State behalten, Effects pausieren und im Hintergrund rendern
Reale Patterns, Performance-Trade-offs und Pitfalls für Tabs/Drawers/Shell-UIs.
04
useEffectEvent Deep Dive: Effect-Design, Subscriptions und Analytics
Linter-freundliche Effect-Grenzen ohne stale closures oder Reconnect-Churn.
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
isPendingvon 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.
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]
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>
);
}Progressive Enhancement: action props, permalink und „funktioniert vor geladenem JS“
React hebt eine Fähigkeit hervor, wenn useActionState mit Server Functions genutzt wird: Frameworks können Server-Responses früher anzeigen und Forms früher interaktiv halten (Progressive Enhancement / vor Abschluss der Hydration). permalink sorgt dafür, dass Pre-Hydration-Submits wissen, wohin navigiert werden soll. [2]
In Next.js sind das Server Functions (Server Actions im Mutation-Kontext): async Funktionen auf dem Server, die per Netzwerk aufgerufen werden. Next betont Transition-Wrapping und Cache-Integration (ein Roundtrip kann UI + neue Daten zurückliefern). [6]
<form action={dispatchAction}> wrapped Submits automatisch in eine Transition; React beschreibt außerdem Progressive Enhancement mit Server Functions und permalink. [2]
Next.js: Server Actions integrieren sich in das Caching-Modell; <form action> / formAction werden automatisch in startTransition gewrappt und Actions laufen via POST. [6]
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]
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]
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-cancellationWann 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):
| 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
Wo eine Data-Layer gewinnt
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]
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-queryPerformance- 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
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
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
Action State ist UI State (Errors, Messages, IDs), kein Cache. Große Objekte erhöhen Render-Kosten und erschweren Reconciliation.
Form-Reset-Verhalten
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
startTransitionlaufen — sonst istisPendingirrefü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
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]
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]
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]
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]
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]
`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]
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.
• 1. React v19 (official): Actions, useActionState, useOptimistic, useFormStatus, form reset + requestFormReset Quelle lesen ↗
• 2. useActionState (official reference): queue semantics, Transitions requirement, permalink, cancellation, error handling caveats Quelle lesen ↗
• 3. useOptimistic (official reference): optimistic state semantics, reducer pattern, re-running reducer on new base data Quelle lesen ↗
• 4. useFormStatus (official reference): reading parent form status without prop drilling Quelle lesen ↗
• 6. Next.js (official): Updating Data with Server Functions (Server Actions), automatic Transition for form action props, caching integration, POST behavior Quelle lesen ↗
• 7. TanStack Query (official): Invalidations from Mutations (invalidateQueries + onSuccess promise semantics) Quelle lesen ↗
• 8. SWR (official): Mutation & Revalidation (useSWRMutation + cache update behavior) Quelle lesen ↗
• 9. Redux Toolkit (official): RTK Query Automated Re-fetching (cache tags invalidation model) Quelle lesen ↗
• 13. PAS7 Studio (published chapter): React <Activity /> deep dive (series next chapter reference) Quelle lesen ↗
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.
useActionState Deep Dive: Mutation-Flows, optimistisches UI und Integrations-Patterns