Tecnologia
useActionState deep dive: flussi di mutation, UI ottimistica, coda di Actions e quando un data layer vince ancora (React 19+)
Un deep dive pratico su useActionState in React 19: come funziona la coda delle Actions, perché le Transitions sono cruciali per isPending, progressive enhancement con permalink, integrazione di useOptimistic per UX immediata, cancellazione con AbortController e un framework decisionale su quando TanStack Query / SWR / RTK Query devono ancora possedere caching, retry e invalidazione.

React 2026 Primitives & Compiler Upgrade Guide
Questo è un capitolo della React 2026 Primitives & Compiler Upgrade Guide. Focus: useActionState — flussi di mutation, optimistic UX, semantica della coda e pattern di integrazione.
Tutti gli articoli di questa guida
01
Overview: React 2026 primitives and compiler-era mental model
Cambiamenti architetturali, cosa è cambiato e dove si inserisce ogni primitivo.
02
useActionState deep dive: flussi di mutation, UI ottimistica e pattern di integrazione
Un deep dive pratico su coda delle Actions, Transitions, optimistic UX e il confine dove il data layer vince ancora.
03
React <Activity />: mantieni lo state, metti in pausa gli Effects e renderizza in background
Pattern reali, trade-off di performance e pitfall per tabs/drawers/shell UI.
04
useEffectEvent deep dive: design degli effects, subscription e analytics
Confini degli effects compatibili col linter senza stale closures o reconnessioni inutili.
Cosa otterrai da questo deep dive
Questo capitolo riguarda correttezza, UX e confini di ownership: cosa garantisce useActionState, cosa non garantisce e come usarlo senza reinventare un data layer incompleto.
• Un mental model per Actions + useActionState (che state conserva e dove gira il lavoro). [1][2]
• La coda delle Actions: esecuzione sequenziale, perché evita alcune race e quando diventa una trappola di performance. [2]
• Perché
isPendingdipende dalle Transitions e i due modi sicuri per triggerare actions. [2]• Progressive enhancement:
permalink, risposte server prima della fine dell’hydration e cosa gestisce di solito il framework. [2][6]• Optimistic UX con
useOptimistic: UI istantanea, rollback e come evitare optimistic state stantio. [1][3]• Pattern di cancellazione con
AbortControllerquando l’utente può “spammare” actions. [2]• Un framework decisionale: quando TanStack Query / SWR / RTK Query devono ancora possedere caching, retry, invalidazione e sincronizzazione. [7][8][9]
• Capitolo successivo: deep dive su React
<Activity />(state retention + comportamento degli Effects + pitfall di rollout). [13]
Perché esiste useActionState: rendere i flussi di mutation di nuovo “noiosi”
La maggior parte delle app ha due responsabilità principali: mostrare dati e modificare dati. Il loading spesso è semplice; la complessità vive nelle mutation — correttezza, UX, debug e gestione degli stati.
La direzione “Actions” in React 19 punta a standardizzare l’handshake delle mutation: pending, optimistic UI e gestione errori senza macchine a stati ad-hoc per ogni form o bottone. [1]
useActionState è uno strato sottile ma fondamentale: incapsula un’Action, memorizza l’ultimo risultato come state ed espone isPending per mantenere la UI reattiva durante l’async. In più, include una semantica chiave: le dispatch sono in coda ed eseguite in modo sequenziale. [2]
La semantica API che conta davvero in produzione
La signature è simile a un reducer: l’action riceve prima previousState, poi il payload (per le form spesso FormData). Il valore di ritorno diventa il prossimo state. [2]
React mette in coda più dispatch e le esegue in sequenza. Ogni run vede il risultato precedente come previousState. È una feature di correttezza, ma significa anche backpressure se l’utente triggera actions più velocemente di quanto completino. [2]
Le Transitions non sono opzionali. Se chiami manualmente il dispatcher, deve essere dentro una Transition (startTransition). Se lo passi a action / formAction, React lo avvolge per te. Altrimenti isPending può comportarsi male e React può avvisare. [2]
Error handling: se l’action lancia, React può saltare le dispatch in coda successive. Per failure attese (validazione, 4xx) è meglio restituire uno state di errore invece di fare throw. Riserva il throw a casi eccezionali gestiti da error boundary. [2]
Progressive enhancement è nel design: permalink è per framework RSC-capable così la form funziona prima che il JS sia caricato. Con Server Functions, anche l’initial state deve essere serializzabile. [2]
Pattern base: submit di form con result state tipizzato
Il caso d’uso che “vince subito” sono le form: errori di validazione lato server, field errors e pending state che disabilita submit senza prop drilling. I suoi esempi ufficiali lo trattano come target primario. [1][2][4]
Un pattern pratico: modellare lo state come discriminated union, così la UI non deve mai “indovinare” cosa contiene.
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>
);
}Un dettaglio importante: le form Actions in React 19 possono resettare gli input uncontrolled dopo un submit riuscito, e React espone requestFormReset per reset manuali. Per flussi “salva bozza” conviene testare uncontrolled vs controlled con attenzione. [1]
Trucco da design system: useFormStatus evita il prop drilling del pending state
Se hai una component library (Button, SubmitButton, FormFooter), passare isPending attraverso i livelli è noioso. useFormStatus legge lo status della form padre come un context ed è pensato esattamente per questo. [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 e “funziona prima che carichi il JS”
React sottolinea una capacità quando useActionState è usato con Server Functions: i framework possono mostrare risposte server e mantenere le form interattive prima (progressive enhancement / prima che l’hydration finisca). permalink serve perché un submit pre-hydration sappia dove navigare. [2]
In Next.js, queste sono Server Functions (Server Actions nel contesto mutation): async sul server invocate via rete. Next evidenzia l’avvolgimento in Transition e il modello di caching (un roundtrip può restituire UI aggiornata e nuovi dati). [6]
Passare il dispatcher a <form action={dispatchAction}> avvolge il submit in una Transition automaticamente; React descrive anche progressive enhancement con Server Functions e permalink. [2]
Next.js: le Server Actions si integrano con l’architettura di caching; <form action> / formAction sono avvolte in startTransition automaticamente e le actions usano POST. [6]
Optimistic UI senza autoinganno: useOptimistic + una storia di rollback reale
Optimistic UI non significa “far finta che sia veloce”. Significa mostrare lo stato finale atteso mentre la request è pending e poter recuperare bene in caso di errore. useOptimistic in React 19 è progettato per lavorare con le Actions: lo stato ottimistico vive durante l’Action e poi React torna allo state source-of-truth quando l’action finisce o fallisce. [1][3]
Un trucco importante dai docs: con useOptimistic(items, reducer), React può rieseguire il reducer se la prop base items cambia mentre l’action è pending. Questo riduce bug di optimistic state stantio in UI collaborative o quasi real-time. [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: quando la consistenza sequenziale diventa backpressure
La coda delle Actions è un dettaglio spesso sottovalutato. La dispatch sequenziale evita una classe di race (risposte tardive che sovrascrivono intenti più recenti). Ma può anche creare backlog: lo spam di click serializza chiamate di rete e rende la UI lenta. React consiglia esplicitamente useOptimistic, cancellazione o un approccio diverso per casi complessi. [2][3]
La cancellazione è utile quando vuoi “latest intent wins”, ma ha trade-off di correttezza: abortire una request non riavvolge una mutation sul server. È più sicuro quando l’effetto è idempotente, ignorabile in sicurezza o retryable. [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 può cancellare Actions pending, ma abortire una request non annulla le mutation lato server. [2]
Screenshot della sezione queued-actions-and-cancellationQuando useActionState riduce il boilerplate — e quando il data layer possiede ancora il problema
Il confine pulito: useActionState è ottimo per l’handshake UI-owned della mutation; i data layer eccellono nella correttezza di sistema (cache, retry, invalidazione, sync cross-screen).
React 19 offre un primitivo migliore per le mutation, ma non prova a sostituire le responsabilità delle data library. Devi comunque decidere chi possiede caching e sincronizzazione. [1][2]
Una tabella decisionale semplice (heuristica, non dogma):
| Esigenza | useActionState | Data layer |
|---|---|---|
| Pending + field/server errors nelle form | ✅ | ✅ |
| Mutation one-off con update UI locale | ✅ | ✅ |
| Caching + dedupe + background refetch | ❌ | ✅ (SWR / TanStack Query) |
| Policy di retry / backoff | ❌ | ✅ |
| Invalidazione cache su molte schermate | ❌ | ✅ |
| Offline queue / persistence | ❌ | ✅ |
| Optimistic UI in ambito locale | ✅ (con useOptimistic) | ✅ |
| Optimistic UI + riconciliazione con cache condivisa | ⚠️ | ✅ |Se la mutation impatta più query o schermate, spesso vuoi che la library che modella quel grafo possieda invalidazione e refetch. TanStack Query tratta l’invalidation come workflow di base; RTK Query fa lo stesso con i tag; SWR ha primitive di mutation + revalidation per motivi simili. [7][9][8]
Dove useActionState brilla
Dove un data layer vince
Pattern di integrazione: useActionState come UI shell, TanStack Query come cache engine
Un ibrido pratico: usa useActionState per success/error a livello form e TanStack Query per invalidare così le altre schermate riflettono la mutation. I docs mostrano invalidateQueries come step standard e notano che restituire una Promise in onSuccess mantiene la mutation pending finché l’invalidation non completa. [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 dopo mutation via onSuccess + invalidateQueries (Promise.all per più chiavi). [7]
Screenshot della sezione integration-example-react-queryNote su performance e correttezza (le cose che mordono dopo)
La maggior parte delle regressioni nasce da responsabilità mischiate o semantiche ignorate: transitions, queueing, reset e dimensione dello state.
Heuristica pratica: nello state dell’action conserva solo ciò che serve alla UI post-mutation (errori, success, id). Dati duraturi o condivisi devono vivere su server, cache del framework o data layer. [1][6][7]
Disciplina delle Transition
La dispatch manuale deve essere dentro startTransition, altrimenti React avvisa e isPending non si comporta correttamente. action/formAction avvolge per te. [2]
Backpressure della coda
La dispatch sequenziale evita alcune race, ma può serializzare interazioni in un backlog. Per rapid-fire usa useOptimistic, cancellazione o un modello diverso. [2][3]
Dimensione del payload nello state
Tratta l’action state come UI state (errori, messaggi, id), non come cache. Oggetti grandi aumentano il costo di render e complicano la riconciliazione.
Reset delle form
Le form Actions possono resettare gli input uncontrolled dopo successo; requestFormReset esiste per reset manuali. Flussi bozza/progresso richiedono una scelta intenzionale. [1]
Una checklist di rollout da dare al team
Se adotti useActionState in tutta la codebase, questi controlli evitano le trappole più comuni di correttezza e UX.
• Modella il risultato dell’action come discriminated union (evita state ambigui).
• Per failure attese restituisci uno state di errore invece di fare throw, così non salti le dispatch in coda. [2]
• La dispatch manuale deve stare in
startTransition, altrimentiisPendingè fuorviante. [2]• Per interazioni “spammy” scegli: consistenza sequenziale, cancellazione (AbortController) o optimistic reducer. [2][3]
• Se la mutation impatta più schermate, delega invalidation/refetch al data layer (o al cache model di Next). [7][9][6]
• Con Server Functions, assicurati che l’initial state sia serializzabile e testa progressive enhancement (
permalink/ submit pre-hydration). [2][6]• Testa i reset degli input uncontrolled e decidi se servono controlled inputs o reset esplicito. [1]
FAQ
No. useActionState è ottimo per l’handshake UI (pending + result + errors). I data layer possiedono ancora caching, retry, invalidazione, background refetch e sincronizzazione cross-screen. Se la mutation impatta più query/route, un data layer (o il cache model del framework come Next.js) è di solito il proprietario giusto. [7][8][9][6]
Se chiami manualmente il dispatcher, deve essere dentro una Transition (`startTransition`). In alternativa, passalo come `action`/`formAction` e React lo avvolge automaticamente. Altrimenti React può avvisare e `isPending` può risultare scorretto. [2]
Sì: React mette le dispatch in coda e le esegue in sequenza, quindi ogni esecuzione vede il risultato precedente come `previousState`. Questo evita alcune bug out-of-order, ma può creare backpressure in percorsi “spammy”. [2]
Usa `useOptimistic` per UI immediata, cancella lavoro pending con `AbortController` quando vuoi “latest intent wins”, o valuta un modello diverso se useActionState non è il primitivo adatto. I docs di React indicano esplicitamente queste escape hatch. [2][3]
Per failure attese (validazione, 4xx) restituisci uno state di errore invece di fare throw. Se l’action lancia, React può saltare le dispatch in coda successive. Riserva il throw a casi eccezionali gestiti da error boundary. [2]
`permalink` è pensato per framework RSC-capable con progressive enhancement. Se una form viene inviata prima che il JS carichi, il browser può navigare al permalink route invece di dipendere dall’URL corrente. Spesso il framework gestisce questo pattern, ma conta se vuoi “funziona prima del JS”. [2][6]
Le form Actions in React 19 possono resettare automaticamente gli input uncontrolled dopo successo. React fornisce anche `requestFormReset` per reset manuali. Nei flussi bozza/progresso conviene scegliere intenzionalmente uncontrolled vs controlled e testare il comportamento. [1]
Fonti
Includiamo solo fonti che supportano direttamente semantica, caveat e integrazioni citate in questo capitolo.
• 1. React v19 (official): Actions, useActionState, useOptimistic, useFormStatus, form reset + requestFormReset Leggi fonte ↗
• 2. useActionState (official reference): queue semantics, Transitions requirement, permalink, cancellation, error handling caveats Leggi fonte ↗
• 3. useOptimistic (official reference): optimistic state semantics, reducer pattern, re-running reducer on new base data Leggi fonte ↗
• 4. useFormStatus (official reference): reading parent form status without prop drilling Leggi fonte ↗
• 6. Next.js (official): Updating Data with Server Functions (Server Actions), automatic Transition for form action props, caching integration, POST behavior Leggi fonte ↗
• 7. TanStack Query (official): Invalidations from Mutations (invalidateQueries + onSuccess promise semantics) Leggi fonte ↗
• 8. SWR (official): Mutation & Revalidation (useSWRMutation + cache update behavior) Leggi fonte ↗
• 9. Redux Toolkit (official): RTK Query Automated Re-fetching (cache tags invalidation model) Leggi fonte ↗
• 13. PAS7 Studio (published chapter): React <Activity /> deep dive (series next chapter reference) Leggi fonte ↗
Vuoi adottare Actions senza regressioni?
useActionState è potente quando il confine resta pulito: la UI possiede l’handshake della mutation, il sistema possiede la correttezza dei dati condivisi.
Se stai facendo upgrade a React 19 (o introducendo Server Actions, optimistic UX e una strategia di caching), PAS7 Studio può fare un audit dei flussi di mutation, definire confini di ownership e supportare un rollout incrementale con guardrail.
useActionState deep dive: flussi di mutation, UI ottimistica e pattern di integrazione
Sei qui: 02/04
useActionState deep dive: flussi di mutation, UI ottimistica e pattern di integrazione