PAS7 Studio

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.

27 feb 2026· 18 min di lettura
Foto in studio di un quaderno: coda di action, note su optimistic UI e frecce delle form di React 19
Guida / SerieArticolo della serie

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.

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

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.

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]

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]

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

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

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

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

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]

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]

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

Note 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

obbligatoria

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

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]

Dimensione del payload nello state

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.

Reset delle form

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]

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

FAQ

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]

Fonti

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

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.

Sei qui02/04

useActionState deep dive: flussi di mutation, UI ottimistica e pattern di integrazione

Articoli correlati

growthFebruary 15, 2026

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”.

Leggere →
telegram-media-saverJanuary 8, 2025

Tag automatici e ricerca per link salvati

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

Leggere →
servicesJanuary 2, 2025

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.

Leggere →
backend-engineeringFebruary 15, 2026

Bun vs Node.js nel 2026: perché Bun sembra più veloce (e come valutare l’app prima di migrare)

Bun è un toolkit JavaScript all-in-one più rapido: runtime, package manager, bundler e test runner. Qui trovi cosa è reale (con benchmark), cosa può rompersi e come ottenere un audit di readiness gratuito con @pas7-studio/bun-ready.

Leggere →

Sviluppo professionale per la tua attività

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