PAS7 Studio
Torna a tutti gli articoli

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.

27 feb 2026· 14 min di lettura· Tecnologia
Ideale perIngegneri frontendTech leadTeam in upgrade a React 19
Foto in studio di un quaderno: coda di action, note su optimistic UI e frecce delle form di React 19

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é isPending dipende 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 AbortController quando 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]

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]

Se un team deve ricordare una sola cosa, dovrebbe essere questo flusso. Molta confusione sparisce quando l'ownership è esplicita.

01

L'intento dell'utente entra tramite un'Action

Un submit di form o una dispatch manuale avvia il flusso di mutation. Se chiami il dispatcher a mano, deve essere dentro startTransition. [2]

02

React traccia pending e mette in coda le dispatch successive

Ogni nuova dispatch aspetta la precedente, e ogni esecuzione riceve l'ultimo risultato completato come previousState. [2]

03

La tua action restituisce stato UI, non una cache

Usa lo state restituito per messaggi, errori di campo, id e UX post-submit locale. Non trasformarlo di nascosto in storage condiviso.

04

L'ownership dei dati condivisi resta altrove

Se la mutation impatta più route/query/consumatori, delega invalidazione e sincronizzazione al modello cache del framework o al data layer. [6][7][8][9]

In breve

Tratta useActionState come il sottile guscio UI attorno a una mutation, non come system of record.

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]

Questi sono gli errori che fanno sembrare useActionState peggiore di quanto sia. L'API è piccola; l'uso scorretto è il problema tipico.

Chiamare il dispatcher fuori da startTransition e poi chiedersi perché isPending è fuorviante. [2]

Fare throw per errori attesi (validazione, 4xx) invece di restituire uno stato di errore strutturato. [2]

Infilare payload grandi nello state dell'action e trasformare l'hook UI in una pseudo-cache.

Usarlo per interazioni ad alta frequenza senza una strategia per backpressure della coda, ottimismo o cancellazione. [2][3]

In breve

La maggior parte dei problemi di rollout viene dal mescolare responsabilità, non dall'hook in sé.

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.

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>
  );
}

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]

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]

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 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]

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>
  );
}

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]

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 può cancellare Actions pending, ma abortire una request non annulla le mutation lato server. [2]

Screenshot della sezione queued-actions-and-cancellation

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):

MD
| 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

Flussi di mutation UI-owned: form, pannelli settings, wizard, bottoni “save”, componenti dove il result state serve solo localmente (e magari un redirect). Meno boilerplate, migliori default UX. [1][2]

Dove un data layer vince

Correttezza dei dati di sistema: cache condivise, orchestrazione refetch, retry, dedupe, offline, consistenza cross-route. Sono responsabilità non banali che useActionState non modella. [7][8][9]

Ibrido comune

Usa useActionState per l’handshake UI (pending + errors), ma delega invalidazione/refetch al data layer (o al cache model di Next.js). Confini più chiari. [6][7]

Comparison pointuseActionState va beneData layer va bene
Pending di form + errori campo/server
Mutation semplice one-off con update UI locale
Caching + dedupe + background refetchNoSì (SWR / TanStack Query)
Policy di retry / backoffNo
Invalidazione cache su molte schermateNo
Code offline / persistenzaNo
Optimistic UI per scope locale piccoloSì (con useOptimistic)
Optimistic UI + riconciliazione cache condivisaRischioso

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]

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 dopo mutation via onSuccess + invalidateQueries (Promise.all per più chiavi). [7]

Screenshot della sezione integration-example-react-query

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]

obbligatoria

La dispatch manuale deve essere dentro startTransition, altrimenti React avvisa e isPending non si comporta correttamente. action/formAction avvolge per te. [2]

attenzione allo spam

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]

tenetelo piccolo

Tratta l’action state come UI state (errori, messaggi, id), non come cache. Oggetti grandi aumentano il costo di render e complicano la riconciliazione.

testate uncontrolled

Le form Actions possono resettare gli input uncontrolled dopo successo; requestFormReset esiste per reset manuali. Flussi bozza/progresso richiedono una scelta intenzionale. [1]

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, altrimenti isPending è 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]

useActionState sostituisce TanStack Query / SWR / RTK Query?

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]

Perché `isPending` non si aggiorna come mi aspetto?

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]

Le Actions sono davvero sequenziali? Cosa ottengo?

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]

Come evito backlog della coda per interazioni rapide?

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]

Qual è il modo sicuro di gestire errori nel reducerAction?

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]

Cos’è permalink e mi serve davvero?

`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]

Perché i campi della form si sono svuotati dopo un submit riuscito?

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]

Includiamo solo fonti che supportano direttamente semantica, caveat e integrazioni citate in questo capitolo.

Verificato: 06 mar 2026Valido per: React 19.xValido per: Next.js App RouterValido per: Server Functions / Server ActionsTestato con: useActionStateTestato con: useOptimisticTestato con: useFormStatusTestato con: TanStack Query v5

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.

Sei qui02/04

useActionState in profondit?: mutation flows, optimistic UI e integration patterns

Articoli correlati

growth

AI SEO / GEO nel 2026: i tuoi prossimi clienti non sono umani — sono agenti

La ricerca sta passando dai click alle risposte. Bot e agenti AI scansionano, citano, raccomandano e sempre più spesso acquistano. Scopri cosa significa AI SEO / GEO, perché la SEO classica non basta più e come PAS7 Studio aiuta i brand a vincere visibilità nel web “agentico”.

blogs

Il chip Apple più potente? M5 Pro e M5 Max battono i record

Analisi di Apple M5 Pro e M5 Max aggiornata a marzo 2026. Spieghiamo perché questi chip possono essere considerati i SoC professionali per notebook più potenti di Apple, come si posizionano contro M4 Pro, M4 Max, M1 Pro, M1 Max e cosa mostrano rispetto ai concorrenti Intel e AMD.

telegram-media-saver

Tag automatici e ricerca per link salvati

Integra con GDrive/S3/Notion per tag automatici e ricerca veloce tramite API di ricerca

services

Sviluppo di bot e servizi di automazione

Sviluppo professionale di bot Telegram e automazione dei processi aziendali: chatbot, assistenti AI, integrazioni CRM, automazione dei flussi di lavoro.

Sviluppo professionale per la tua attività

Creiamo soluzioni web moderne e bot per le aziende. Scopri come possiamo aiutarti a raggiungere i tuoi obiettivi.