Módulo 16: Actions

Objetivos del módulo

  • Entender qué problema resuelven las Actions y qué parte del trabajo sigue siendo tuya
  • Pasar una función asíncrona a startTransition y usar isPending sin escribir un estado de carga a mano
  • Saber por qué una actualización después de un await necesita otro startTransition
  • Gestionar el resultado de una operación con useActionState: estado anterior, cola de acciones, tipos y cancelación
  • Devolver los errores de validación de la API EMPRESA a cada campo sin perder lo que escribió el usuario
  • Crear componentes de formulario reutilizables con useFormStatus
  • Mostrar el resultado antes de que responda el servidor con useOptimistic, y deshacerlo solo si falla
  • Controlar cuándo se vacía un formulario con requestFormReset
  • Separar los errores esperados de los inesperados y llevar estos últimos a un Error Boundary
  • Combinar todas las piezas en una pantalla completa contra la API EMPRESA
  • Elegir la herramienta adecuada para cada operación y diagnosticar los fallos más frecuentes

🆕 Desde React 19.0. Casi todo este módulo depende de las Actions, que no existen en React 18.3 ni anteriores. Si usas una versión anterior: escribe el ciclo a mano con useState, try, catch y finally, como en el primer ejemplo de la sección 1, y actualiza a 19 cuando puedas. Los cambios de 19.1, 19.2 y 19.3 no alteran nada de lo que se enseña aquí: hemos ejecutado las pruebas de comportamiento en 19.0.9, 19.2.9 y 19.3.1 y el resultado coincide.


1. El ciclo que escribes en cada operación

Casi todo lo que hace una aplicación de gestión al guardar sigue la misma coreografía: el usuario pulsa, marcas que hay una operación en marcha, lanzas la petición, recoges el resultado o el error, y desmarcas. Así queda con lo que ya sabes, cambiando el estado del pedido 5001 de la API EMPRESA:

// Ejemplo e01: src/App.tsx
import { useState } from 'react';
import { escribir } from './api';

interface Pedido {
  pedidoId: number;
  estado: string;
}

export default function EstadoPedido() {
  const [estado, setEstado] = useState('PENDIENTE');
  const [guardando, setGuardando] = useState(false); // 1. ¿Está en marcha?
  const [error, setError] = useState<string | null>(null); // 2. ¿Ha fallado?
  const [guardado, setGuardado] = useState<string | null>(null); // 3. ¿Qué devolvió?

  async function guardar() {
    setGuardando(true);
    setError(null);
    try {
      const pedido = await escribir<Pedido>('PATCH', '/pedidos/5001', { estado });
      setGuardado(pedido.estado);
    } catch (e) {
      setError(e instanceof Error ? e.message : 'Error desconocido');
    } finally {
      setGuardando(false); // Si se te olvida, el botón se queda bloqueado para siempre
    }
  }

  return (
    <section className="tarjeta">
      <h2>Pedido 5001</h2>
      <div className="fila">
        <select value={estado} aria-label="Estado" onChange={(e) => setEstado(e.target.value)}>
          <option>PENDIENTE</option>
          <option>ENVIADO</option>
          <option>EXTRAVIADO</option>
        </select>
        <button onClick={guardar} disabled={guardando}>
          {guardando ? 'Guardando...' : 'Guardar'}
        </button>
      </div>
      {error && <p className="error">{error}</p>}
      {guardado && <p className="ok">Estado guardado: {guardado}</p>}
    </section>
  );
}

Funciona, y es lo que has escrito hasta ahora. Pero fíjate en cuánto código no habla del pedido: tres estados y un try/catch/finally solo para vigilar una llamada. Y aún faltan cosas que una aplicación real necesita:

  • Respuestas rápidas. Si cada guardado tarda un segundo, la interfaz parece lenta aunque la operación casi nunca falle.
  • Orden. Dos clics seguidos lanzan dos peticiones que pueden responder al revés.
  • Reinicio. Hay que vaciar el formulario al terminar, y solo si ha ido bien.
  • Avisar a los hijos. Un botón de envío reutilizable necesita saber que su formulario está enviando, y eso obliga a pasarle props.

Las Actions son la respuesta de React 19 a todo esto. Una Action es una función que ejecuta una operación, normalmente asíncrona, y que React vigila de principio a fin. React sabe cuándo empieza y cuándo termina, de modo que puede encargarse de lo que ocurre alrededor: el estado de espera, el orden, los errores, el valor optimista y el reinicio del formulario.

Dos columnas que comparan cinco tareas hechas a mano con su equivalente en Actions

Figura 1. Las Actions no hacen la petición por ti: se ocupan de lo que la rodea.

📘 Concepto: Una función es una Action cuando la ejecuta startTransition o cuando la recibe una prop que espera una Action, como action en <form> o formAction en <button>. No hay ninguna palabra clave: lo que la convierte en Action es quién la llama.

El módulo 08 presentó <form action>: pasar una función a un formulario, recibir el FormData, usar formAction en varios botones, el reinicio automático y el comportamiento básico ante un error. Aquí no lo repetimos. Partimos de ahí y vemos el mecanismo que hay debajo y los hooks que lo completan. También usamos startTransition, que ya conoces del módulo 15, pero con una novedad importante: ahora admite funciones asíncronas.

Prepara el proyecto

Crea un proyecto como en el módulo 01, donde se explica también qué cambia en PowerShell:

npm create vite@latest modulo-16 -- --template react-ts
cd modulo-16
npm install

Descarga empresa-api.zip, descomprímelo y arranca la API en otra terminal. La variable RETARDO añade una espera a cada respuesta: con 800 ms verás con calma los estados intermedios.

RETARDO=800 node server.mjs

En PowerShell, la misma orden es $env:RETARDO=800; node server.mjs. Para que /api llegue a la API, añade el proxy del módulo 01 a vite.config.ts:

import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [react()],
  server: { proxy: { '/api': 'http://localhost:3000' } },
});

Crea src/api.ts. Es un cliente mínimo que convierte las respuestas de error en una clase propia, ErrorApi, con el código HTTP y los errores de cada campo. Lo importarán todos los ejemplos:

// Pequeño cliente de la API EMPRESA para los ejemplos de este módulo.
const BASE = '/api';

export class ErrorApi extends Error {
  status: number;
  detalles: Record<string, string>;

  constructor(status: number, mensaje: string, detalles: Record<string, string> = {}) {
    super(mensaje);
    this.name = 'ErrorApi';
    this.status = status;
    this.detalles = detalles;
  }
}

async function tratar<T>(respuesta: Response): Promise<T> {
  if (respuesta.status === 204) return undefined as T;
  const cuerpo = await respuesta.json().catch(() => null); // Un 500 puede venir sin JSON
  if (!respuesta.ok) {
    const mensaje = cuerpo?.mensaje ?? `El servidor ha respondido con un error ${respuesta.status}`;
    throw new ErrorApi(respuesta.status, mensaje, cuerpo?.detalles);
  }
  return cuerpo as T;
}

export async function leer<T>(ruta: string, signal?: AbortSignal): Promise<T> {
  return tratar<T>(await fetch(BASE + ruta, { signal }));
}

export async function iniciarSesion(email: string, password: string) {
  const respuesta = await fetch(`${BASE}/auth/login`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ email, password }),
  });
  return tratar<{ token: string; usuario: { nombre: string; rol: string } }>(respuesta);
}

// Para no repetir el inicio de sesión en cada ejemplo, entramos una vez como ADMIN.
// En el proyecto final la sesión la abre de verdad el usuario.
let token: Promise<string> | null = null;

function obtenerToken() {
  token ??= iniciarSesion('ana.garcia@empresa.es', 'secreto').then((s) => s.token);
  return token;
}

export async function escribir<T>(metodo: string, ruta: string, cuerpo?: unknown): Promise<T> {
  const respuesta = await fetch(BASE + ruta, {
    method: metodo,
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${await obtenerToken()}`,
    },
    body: cuerpo === undefined ? undefined : JSON.stringify(cuerpo),
  });
  return tratar<T>(respuesta);
}

export const espera = (ms: number) => new Promise<void>((resolver) => setTimeout(resolver, ms));

export interface Pagina<T> {
  contenido: T[];
  total: number;
}

export interface Departamento {
  deptId: number;
  nombre: string;
  localidad: string;
  presupuesto: number | null;
}

Cada ejemplo completo sustituye a src/App.tsx. Borra src/App.css y sustituye src/index.css por estas reglas:

:root {
  font-family: 'Segoe UI', system-ui, sans-serif;
  color: #263238;
  background: #f5f6fa;
}

body {
  margin: 0;
  padding: 1.25rem;
}

.tarjeta {
  background: #fff;
  border: 1px solid #c5cae9;
  border-radius: 10px;
  padding: 1rem 1.25rem;
  max-width: 620px;
  margin: 0 0 1rem;
}

.tarjeta h2 {
  margin: 0 0 0.75rem;
  font-size: 1.15rem;
  color: #1a237e;
}

.fila {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  align-items: center;
}

label {
  display: block;
  margin-bottom: 0.6rem;
  font-weight: 600;
  font-size: 0.9rem;
}

input:not([type='checkbox']),
select {
  display: block;
  box-sizing: border-box;
  width: 100%;
  margin-top: 0.2rem;
  padding: 0.4rem 0.5rem;
  border: 1px solid #9fa8da;
  border-radius: 6px;
  font: inherit;
  font-weight: 400;
}

.fila input,
.fila select {
  width: auto;
  margin-top: 0;
}

button {
  padding: 0.4rem 0.9rem;
  border: 1px solid #283593;
  border-radius: 6px;
  background: #3f51b5;
  color: #fff;
  font: inherit;
  cursor: pointer;
}

button.secundario {
  background: #fff;
  color: #283593;
}

button:disabled {
  opacity: 0.55;
  cursor: wait;
}

fieldset {
  border: 0;
  margin: 0;
  padding: 0;
}

ul.lista {
  list-style: none;
  margin: 0.75rem 0 0;
  padding: 0;
}

ul.lista li {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 0.5rem;
  padding: 0.4rem 0;
  border-top: 1px solid #eceff1;
}

.pendiente {
  opacity: 0.55;
  font-style: italic;
}

.error {
  display: block;
  color: #c62828;
  font-size: 0.9rem;
  font-weight: 400;
  margin: 0.25rem 0;
}

.ok {
  color: #2e7d32;
  margin: 0.5rem 0 0;
}

.nota {
  color: #607d8b;
  font-size: 0.9rem;
  font-weight: 400;
  margin: 0.5rem 0 0;
}

La API guarda los datos en memoria. Si haces muchas pruebas, reiníciala para volver a los datos iniciales.


2. Acciones asíncronas en transiciones 🆕 19.0

🆕 Desde React 19.0. No existe en React 18.3 ni anteriores: startTransition acepta la función asíncrona, pero no espera a la promesa, e isPending vuelve a false enseguida. Si usas una versión anterior: mantén un estado guardando con useState y apágalo en un finally.

En el módulo 15, startTransition envolvía un cálculo síncrono. Desde React 19 también admite una función async, y la transición dura hasta que la promesa se resuelve. Este es el mismo componente, sin el estado guardando ni el finally:

// Ejemplo e02: src/App.tsx
import { useState, useTransition } from 'react';
import { escribir } from './api';

interface Pedido {
  pedidoId: number;
  estado: string;
}

export default function EstadoPedido() {
  const [estado, setEstado] = useState('PENDIENTE');
  const [error, setError] = useState<string | null>(null);
  const [guardado, setGuardado] = useState<string | null>(null);
  const [guardando, iniciar] = useTransition();

  function guardar() {
    iniciar(async () => {
      setError(null);
      try {
        const pedido = await escribir<Pedido>('PATCH', '/pedidos/5001', { estado });
        iniciar(() => setGuardado(pedido.estado)); // Tras un await hay que volver a envolver
      } catch (e) {
        iniciar(() => setError(e instanceof Error ? e.message : 'Error desconocido'));
      }
    });
  }

  return (
    <section className="tarjeta">
      <h2>Pedido 5001</h2>
      <div className="fila">
        <select value={estado} aria-label="Estado" onChange={(e) => setEstado(e.target.value)}>
          <option>PENDIENTE</option>
          <option>ENVIADO</option>
          <option>EXTRAVIADO</option>
        </select>
        <button onClick={guardar} disabled={guardando}>
          {guardando ? 'Guardando...' : 'Guardar'}
        </button>
      </div>
      {error && <p className="error">{error}</p>}
      {guardado && <p className="ok">Estado guardado: {guardado}</p>}
    </section>
  );
}

guardando ya no es un estado tuyo: es el primer valor de useTransition. Vale true desde el clic hasta que termina la función, y vuelve a false tanto si acaba bien como si falla. No hay finally que olvidar.

Hemos medido el mismo botón, con una función que espera 300 ms, en dos versiones:

Versión isPending pasa a true isPending pasa a false
19.3.1 7 ms 312 ms, al terminar la función
18.3.1 5 ms 10 ms, sin esperar a la promesa

⚠️ Importante: La función que recibe startTransition puede ser async, pero el manejador que la llama no necesita serlo. guardar es una función normal y lo que hay dentro de iniciar es lo asíncrono.

Las actualizaciones después de un await

En e02 hay algo que llama la atención: setError(null) va directamente, pero setGuardado lleva otro iniciar alrededor. No es un capricho. startTransition marca como transición lo que ocurre mientras se ejecuta de forma síncrona su función. Al llegar al primer await, la función se suspende y startTransition ya ha terminado. El código que sigue se ejecuta más tarde, desde una promesa, y React ya no sabe que pertenecía a la transición.

Lo hemos medido con una Action que espera 200 ms, actualiza un valor y espera otros 600 ms:

iniciar(async () => {
  await espera(200);
  setValor((v) => v + 1); // Sin envolver: se aplica a los 200 ms
  await espera(600);
});

iniciar(async () => {
  await espera(200);
  startTransition(() => setValor((v) => v + 1)); // Envuelta: se aplica a los 800 ms
  await espera(600);
});
Forma de actualizar isPending a false El valor nuevo se ve a los
Sin envolver 819 ms 208 ms
Con startTransition 813 ms 813 ms

Cronología de una Action con isPending y dos filas que muestran cuándo se ve el valor nuevo

Figura 2. Sin envolver, el valor llega antes que el fin de la espera. Envuelto, ambos llegan en el mismo commit.

Sin envolver, el usuario ve un estado incoherente: el dato ya ha cambiado, pero el botón sigue diciendo “Guardando…”. Envuelto, el valor nuevo y isPending = false llegan juntos. Es una limitación conocida de JavaScript, no un defecto de tu código: no hay forma de conservar el contexto de una transición a través de un await sin ayuda del lenguaje.

💡 Consejo: La regla práctica es sencilla. Antes del primer await escribe normal. Después del primer await, envuelve cada setState en startTransition, o deja que lo haga useActionState, que resuelve este problema de raíz porque el nuevo estado es lo que devuelves.

startTransition del hook o del paquete

react ofrece dos startTransition, y no son iguales ante un error:

Forma ¿Da isPending? Si la función lanza una excepción
const [p, iniciar] = useTransition() Sí La recoge el Error Boundary más cercano
import { startTransition } from 'react' No Llega a window como error sin capturar; la interfaz sigue en pie

Lo hemos comprobado con una excepción lanzada dentro de una función asíncrona: con el hook apareció el Error Boundary, y con la función del paquete no, solo un error sin capturar en la consola. Por eso, si tu Action puede lanzar excepciones que no atrapas, llámala con el iniciar del hook. La función del paquete queda para lo que no necesita saber si hay espera, como envolver un setState tras un await, y además se puede usar fuera de los componentes.

Varias Actions a la vez

Dos Actions lanzadas al mismo tiempo se ejecutan en paralelo, pero sus resultados no se muestran por separado. Probamos dos transiciones con dos useTransition distintos, una de 200 ms y otra de 1000 ms:

   5 ms  isPending1=true, isPending2=true
1013 ms  isPending1=false, isPending2=false, resultado1 y resultado2 visibles

La Action corta terminó a los 200 ms, pero su resultado esperó a la larga. React agrupa las Actions en curso y las publica juntas al acabar la última. La documentación de useActionState lo reconoce como una limitación que podría eliminarse en el futuro.

⚠️ Importante: Las notas de React 19.3 dicen que las transiciones se renderizan de forma independiente, y verás ese cambio en el módulo 17. No afecta a este caso: hemos repetido la prueba con funciones asíncronas en 19.0.9, 19.2.9 y 19.3.1, y las tres versiones dan el mismo resultado. Si una operación lenta no debe retener a otra rápida, plantéate no meter la lenta en una Action.


3. useActionState: el resultado como estado 🆕 19.0

🆕 Desde React 19.0. No existe en React 18.3 ni anteriores. Si usas una versión anterior: usa useReducer o useState más un estado de espera propio, y gestiona a mano el orden de las peticiones. En las versiones canary previas a 19 se llamaba useFormState y estaba en react-dom.

useTransition te dice si algo está en marcha, pero no qué ha pasado. En un formulario de verdad quieres guardar el resultado: los errores, el registro creado, un mensaje. useActionState junta las dos cosas. Piensa en él como un useReducer asíncrono, en el que la función recibe el estado anterior y lo que devuelve es el estado nuevo.

const [estado, despachar, pendiente] = useActionState(funcion, estadoInicial);
Elemento Qué es
funcion(anterior, carga) Tu lógica. Puede ser síncrona o asíncrona. Lo que devuelve será el nuevo estado
estadoInicial El valor antes de la primera ejecución. React lo ignora después
estado Lo que devolvió la última ejecución
despachar(carga) La función que pones en marcha. Se pasa a action o se llama dentro de una transición
pendiente true mientras queden acciones por terminar en este hook

También admite un tercer argumento, permalink, una URL para que el formulario funcione antes de que cargue el JavaScript. Solo tiene efecto con Server Components, y lo verás en el módulo 23.

Cuando pasas despachar a <form action>, la carga es el FormData. Aquí tienes un alta de departamento contra la API. La función está fuera del componente porque no necesita nada de él, y TypeScript deduce los tipos de EstadoAlta y FormData a partir de su firma:

// Ejemplo e04: src/App.tsx
import { useActionState } from 'react';
import { ErrorApi, escribir } from './api';
import type { Departamento } from './api';

interface Valores {
  nombre: string;
  localidad: string;
  presupuesto: string;
}

interface EstadoAlta {
  valores: Valores; // Lo que escribió el usuario, para devolvérselo al formulario
  errores: Record<string, string>; // Un mensaje por campo
  general: string | null; // Un error que no es de ningún campo
  creado: Departamento | null;
}

const VACIO: Valores = { nombre: '', localidad: 'Madrid', presupuesto: '' };
const INICIAL: EstadoAlta = { valores: VACIO, errores: {}, general: null, creado: null };

// Fuera del componente: no depende de props ni de estado, y se prueba sin React
async function crearDepartamento(_anterior: EstadoAlta, datos: FormData): Promise<EstadoAlta> {
  const valores: Valores = {
    nombre: String(datos.get('nombre')),
    localidad: String(datos.get('localidad')),
    presupuesto: String(datos.get('presupuesto')),
  };

  try {
    const creado = await escribir<Departamento>('POST', '/departamentos', {
      nombre: valores.nombre,
      localidad: valores.localidad,
      presupuesto: valores.presupuesto === '' ? null : Number(valores.presupuesto),
    });
    return { ...INICIAL, creado };
  } catch (e) {
    if (e instanceof ErrorApi && e.status === 422) {
      return { ...INICIAL, valores, errores: e.detalles };
    }
    if (e instanceof ErrorApi) {
      return { ...INICIAL, valores, general: e.message };
    }
    throw e; // Fallo inesperado: que lo recoja un Error Boundary
  }
}

export default function AltaDepartamento() {
  const [estado, accion, enviando] = useActionState(crearDepartamento, INICIAL);
  const { valores, errores, general, creado } = estado;

  return (
    <form action={accion} className="tarjeta">
      <h2>Nuevo departamento</h2>

      <label>
        Nombre
        <input
          name="nombre"
          defaultValue={valores.nombre}
          aria-invalid={errores.nombre ? true : undefined}
        />
        {errores.nombre && <span className="error">{errores.nombre}</span>}
      </label>

      <label>
        Localidad
        {/* La key vuelve a crear el select: ver la advertencia del texto */}
        <select key={valores.localidad} name="localidad" defaultValue={valores.localidad}>
          <option>Madrid</option>
          <option>Barcelona</option>
          <option>Sevilla</option>
          <option>Valencia</option>
        </select>
      </label>

      <label>
        Presupuesto
        <input
          name="presupuesto"
          type="number"
          defaultValue={valores.presupuesto}
          aria-invalid={errores.presupuesto ? true : undefined}
        />
        {errores.presupuesto && <span className="error">{errores.presupuesto}</span>}
      </label>

      <button disabled={enviando}>{enviando ? 'Creando...' : 'Crear departamento'}</button>

      {general && <p className="error" role="alert">{general}</p>}
      {creado && (
        <p className="ok" role="status">
          Creado {creado.nombre} con el identificador {creado.deptId}.
        </p>
      )}
    </form>
  );
}

No hay ningún setState y, por tanto, tampoco el problema del await: la función devuelve un objeto y React lo convierte en el estado. Si envías VENTAS, que ya existe, con presupuesto negativo, la API responde 422 con un mensaje por campo:

Formulario de nuevo departamento con mensajes de error bajo el nombre y el presupuesto, y los valores escritos conservados

Figura 3. Los errores 422 de la API aparecen en su campo y el formulario conserva lo escrito.

Si lo corriges, el departamento se crea y el formulario vuelve a estar vacío:

Formulario vacío con un mensaje verde que confirma la creación del departamento

Figura 4. Al devolver INICIAL, los valores por defecto vuelven a estar vacíos.

Conservar lo escrito tras un error

<form action> se reinicia al terminar la Action, también si ha fallado. Sin hacer nada, un error de validación vaciaría el formulario, lo que resulta desesperante para el usuario. El ejemplo lo evita con un truco sencillo: la función devuelve lo que recibió en valores y cada campo lo usa como defaultValue. El reinicio restaura los valores por defecto, y estos ya son lo que escribió el usuario. Si todo va bien, INICIAL trae valores vacíos y el formulario se vacía de verdad.

⚠️ Importante: <select> no sigue esta regla. Tras el reinicio, un select no controlado vuelve al defaultValue de antes de la actualización. En nuestra prueba enviamos b, la Action lo devolvió y el defaultValue pasó a b, pero el campo volvió a a, mientras que un input de texto, uno numérico y un checkbox sí conservaron su valor nuevo. Ocurre igual en 19.0.9, 19.2.9 y 19.3.1. Por eso el ejemplo pone key={valores.localidad} en el select: al cambiar la clave, React crea un select nuevo con el valor correcto. Otra opción es hacerlo controlado con useState.

Acción síncrona, sin carga y con varias órdenes

La carga puede ser cualquier cosa, no solo un FormData. Y como en un reductor, es habitual enviar órdenes con un campo tipo, para que un mismo estado responda a varias acciones. Lo practicarás en el ejercicio 4. Si la función no usa la carga, despachar() no lleva argumentos.

useActionState no trae una función para volver al estado inicial. Las dos salidas habituales son aceptar una orden { tipo: 'reiniciar' } en tu función, o cambiar la key del componente para crearlo de nuevo, como hace el BarreraErrores de la sección 7.

La cola: una acción detrás de otra

Esta es la diferencia más importante con useTransition. Las llamadas a despachar de un mismo hook no van en paralelo: se ponen en cola, y cada una recibe como estado anterior lo que devolvió la previa.

// Ejemplo e05: src/App.tsx
import { startTransition, useActionState } from 'react';
import { espera } from './api';

async function anadir(cantidad: number, unidades: number): Promise<number> {
  console.log(`empieza +${unidades} con cantidad=${cantidad}`);
  await espera(500);
  console.log(`termina +${unidades} con cantidad=${cantidad + unidades}`);
  return cantidad + unidades;
}

export default function Cesta() {
  const [cantidad, despachar, ocupado] = useActionState(anadir, 0);

  function tresVeces() {
    startTransition(() => {
      despachar(1);
      despachar(2);
      despachar(3);
    });
  }

  return (
    <section className="tarjeta">
      <h2>Portátil UltraBook 14</h2>
      <p>Unidades en la cesta: <strong>{cantidad}</strong> {ocupado && '(actualizando...)'}</p>
      <div className="fila">
        <button onClick={tresVeces}>Añadir 1, 2 y 3</button>
        <button className="secundario" onClick={() => despachar(1)}>Mal: fuera de una transición</button>
      </div>
    </section>
  );
}

Al pulsar “Añadir 1, 2 y 3”, la consola muestra esto:

empieza +1 con cantidad=0
termina +1 con cantidad=1
empieza +2 con cantidad=1
termina +2 con cantidad=3
empieza +3 con cantidad=3
termina +3 con cantidad=6

Tarjeta con cero unidades en la cesta y el texto actualizando mientras avanza la cola

Figura 5. Mientras la cola avanza, ocupado es true y la cantidad no cambia.

Cola de tres acciones en la que cada una recibe el resultado de la anterior

Figura 6. La cola serializa las operaciones y encadena sus resultados.

La pantalla pasa de 0 a 6 de una vez, cuando la cola se vacía. En e02, dos clics lanzaban dos peticiones que podían cruzarse; aquí es imposible, porque la segunda no empieza hasta que acaba la primera. Eso sí, tiene un coste: si cada acción tarda un segundo, tres clics tardan tres segundos. Para aliviar esa espera está useOptimistic, y si necesitas operaciones en paralelo, usa useTransition.

Fíjate también en que el texto solo aparece una vez por acción, aunque el ejemplo se ejecute en <StrictMode>. React no invoca dos veces esta función, porque está pensada para tener efectos secundarios.

Llamar a despachar fuera de una transición

despachar debe ejecutarse desde una Action. Con <form action> ocurre solo, pero si la llamas desde un onClick tienes que envolverla en startTransition. El segundo botón de e05 lo hace mal a propósito, y esto es lo que ocurre:

An async function with useActionState was called outside of a transition.
This is likely not what you intended (for example, isPending will not update correctly).

La función se ejecuta igualmente y el estado se actualiza al final, pero ocupado no llega a ser true: en la prueba, la tarjeta nunca mostró “actualizando…”. Por eso es un error fácil de pasar por alto.

Cancelar con AbortController

La cola tiene una consecuencia molesta en un buscador: si el usuario lanza tres búsquedas, hay que esperar a las dos primeras, aunque sus resultados ya no interesen. React no ofrece un botón de cancelar, pero puedes terminar antes la acción en curso con un AbortController, que ya usaste en el módulo 09. La señal viaja en la carga:

// Ejemplo e06: src/App.tsx
import { startTransition, useActionState, useRef } from 'react';
import type { SubmitEvent } from 'react';
import { leer } from './api';
import type { Pagina } from './api';

interface Empleado {
  empId: number;
  nombre: string;
  apellidos: string;
}

interface Consulta {
  texto: string;
  signal: AbortSignal;
}

interface Resultado {
  empleados: Empleado[];
  aviso: string | null;
}

async function buscar(anterior: Resultado, { texto, signal }: Consulta): Promise<Resultado> {
  try {
    const pagina = await leer<Pagina<Empleado>>(
      `/empleados?q=${encodeURIComponent(texto)}&tamano=5`,
      signal,
    );
    return { empleados: pagina.contenido, aviso: null };
  } catch (e) {
    if (e instanceof DOMException && e.name === 'AbortError') {
      return { ...anterior, aviso: `Búsqueda de "${texto}" cancelada` };
    }
    throw e;
  }
}

export default function Buscador() {
  const [resultado, despachar, buscando] = useActionState(buscar, { empleados: [], aviso: null });
  const controlador = useRef<AbortController | null>(null);

  function enviar(evento: SubmitEvent<HTMLFormElement>) {
    evento.preventDefault();
    const datos = new FormData(evento.currentTarget);
    controlador.current?.abort(); // Termina ya la acción anterior para que la cola avance
    controlador.current = new AbortController();
    const { signal } = controlador.current;
    startTransition(() => despachar({ texto: String(datos.get('q')), signal }));
  }

  return (
    <section className="tarjeta">
      <h2>Buscar empleados</h2>
      <form onSubmit={enviar} className="fila">
        <input name="q" placeholder="garcia" aria-label="Texto de búsqueda" />
        <button>Buscar</button>
      </form>
      {buscando && <p className="nota">Buscando...</p>}
      {resultado.aviso && <p className="nota">{resultado.aviso}</p>}
      <ul className="lista">
        {resultado.empleados.map((e) => (
          <li key={e.empId}>{e.nombre} {e.apellidos}</li>
        ))}
      </ul>
    </section>
  );
}

Lanzamos dos búsquedas seguidas, gar y martinez, con la API tardando 400 ms. La primera se aborta a los 60 ms, su acción termina devolviendo el estado anterior, la cola avanza y solo se muestra Luis Martínez Ruiz. El aviso de cancelación queda sustituido en el mismo commit, porque la segunda acción lo borra antes de que se pinte.

⚠️ Importante: Abortar es seguro al leer. Al escribir, cortar la conexión no deshace nada: el servidor puede haber recibido la petición y haber guardado el cambio. Por eso useActionState no aborta por su cuenta. Usa esta técnica en consultas, no en altas ni borrados.

useFormState, el nombre antiguo

Es posible que encuentres useFormState, importado de react-dom, en tutoriales antiguos. Sigue exportado en 19.3.1 y en sus tipos, pero avisa en desarrollo: “ReactDOM.useFormState has been renamed to React.useActionState”. Cambia el nombre y el import: el resto es igual.


4. useFormStatus: el formulario visto desde dentro 🆕 19.0

🆕 Desde React 19.0. No existe en React 18.3 ni anteriores: react-dom 18.3.1 no lo exporta. Si usas una versión anterior: pasa la marca de envío por props o con un Context propio, como en el módulo 11.

En e04, el botón necesita saber si se está enviando, y lo sabe porque está en el mismo componente que useActionState. Pero un buen botón de envío es un componente aparte, reutilizable. useFormStatus, de react-dom, permite que cualquier componente averigüe el estado del <form> que lo contiene, sin recibir props. Funciona como si el formulario fuera un proveedor de Context.

const { pending, data, method, action } = useFormStatus();
Campo Mientras se envía En reposo
pending true false
data El FormData enviado null
method 'get' o 'post', en texto null
action La función que se está ejecutando null

Los tipos son una unión discriminada: tras comprobar pending, TypeScript sabe que data no es null. El módulo 13 explica esa técnica. En este ejemplo, tres componentes hijos reaccionan al envío sin recibir ningún dato del padre:

// Ejemplo e07: src/App.tsx
import { useState } from 'react';
import { useFormStatus } from 'react-dom';
import { espera } from './api';

type Accion = (datos: FormData) => void | Promise<void>;

// Reutilizable: sirve en cualquier formulario, sin recibir ninguna prop de estado
function BotonEnviar({ texto, accion }: { texto: string; accion: Accion }) {
  const { pending, action } = useFormStatus();
  return (
    <button formAction={accion} disabled={pending}>
      {pending && action === accion ? 'Enviando...' : texto}
    </button>
  );
}

function Progreso() {
  const { pending, data, method } = useFormStatus();
  if (!pending) return null;
  return <p className="nota">Enviando "{String(data.get('nombre'))}" (method={method})</p>;
}

function Campos() {
  const { pending } = useFormStatus();
  return (
    <fieldset disabled={pending}>
      <label>
        Nombre del producto
        <input name="nombre" defaultValue="Ratón inalámbrico" />
      </label>
    </fieldset>
  );
}

export default function FichaProducto() {
  const [registro, setRegistro] = useState('Sin cambios');

  async function publicar(datos: FormData) {
    await espera(1000);
    setRegistro(`Publicado: ${datos.get('nombre')}`);
  }

  async function guardarBorrador(datos: FormData) {
    await espera(1000);
    setRegistro(`Borrador: ${datos.get('nombre')}`);
  }

  return (
    <form className="tarjeta">
      <h2>Ficha de producto</h2>
      <Campos />
      <div className="fila">
        <BotonEnviar texto="Publicar" accion={publicar} />
        <BotonEnviar texto="Guardar borrador" accion={guardarBorrador} />
      </div>
      <Progreso />
      <p className="ok">{registro}</p>
    </form>
  );
}

Ficha de producto con los dos botones desactivados, uno con el texto Enviando y un aviso con el nombre enviado

Figura 7. Durante el envío, los hijos desactivan los campos y muestran qué se envía.

Al pulsar “Guardar borrador”, los dos botones se desactivan, pero solo ese muestra “Enviando…”: cada uno compara su accion con la action que informa el hook. Esa comparación es de identidad, así que depende de que la función sea la misma que se envió. Aquí lo es, porque FichaProducto no se vuelve a renderizar mientras dura el envío. Si el padre se renderizase en ese intervalo, crearía funciones nuevas y la comparación fallaría; en ese caso, define las acciones fuera del componente.

Dos detalles: method vale get porque es el valor por defecto de <form>, aunque con una Action no se navega a ninguna parte. Y el estado es del formulario, no del componente: <fieldset disabled> desactiva de golpe todos sus campos, lo que evita que el usuario edite lo que ya se está enviando.

⚠️ Importante: useFormStatus solo funciona en un componente hijo de un <form> con una Action, nunca en el mismo componente que lo renderiza. Lo probamos en los dos casos que fallan, y en ambos pending se queda en false sin ningún aviso en la consola: llamarlo en el componente que devuelve el <form>, y usarlo con un <form onSubmit>.


5. useOptimistic: no hagas esperar al usuario 🆕 19.0

🆕 Desde React 19.0. No existe en React 18.3 ni anteriores. Si usas una versión anterior: guarda el valor nuevo con useState antes de la petición, conserva una copia del anterior y restáuralo en el catch. Tendrás que resolver a mano las operaciones que se solapan.

Cuando pulsas “me gusta” en una red social, el corazón se llena al instante, aunque la petición tarde. La aplicación apuesta a que irá bien, y si falla lo corrige. Es la interfaz optimista, y tiene sentido cuando casi todas las operaciones salen bien y la espera se nota.

useOptimistic mantiene dos valores: el real, que sigues guardando donde quieras, y uno provisional que solo existe mientras dura una Action.

const [vista, setVista] = useOptimistic(valorReal);
const [vista, aplicar] = useOptimistic(valorReal, reductor);

Sin Action en curso, vista es exactamente valorReal. Dentro de una Action, setVista cambia lo que se ve. Al terminar, React descarta el valor provisional y vista vuelve a ser valorReal. Lo más importante es lo que no tienes que hacer: nunca deshaces nada. Si el servidor acepta, actualizas el valor real. Si rechaza, no lo tocas, y la pantalla vuelve sola a lo que era.

Dos escenarios con el valor real y lo que se ve en pantalla antes y después de terminar la Action

Figura 8. Si el servidor acepta, nadie nota el cambio de valor provisional a real. Si rechaza, basta con no actualizar el valor real.

// Ejemplo e08: src/App.tsx
import { useOptimistic, useState, useTransition } from 'react';
import { espera } from './api';

// Simula el servidor: tarda un segundo y a veces rechaza la operación
async function guardarFavorito(favorito: boolean, fallar: boolean) {
  await espera(1000);
  if (fallar) throw new Error('El servidor no ha podido guardar el favorito');
  return favorito;
}

export default function Favorito() {
  const [favorito, setFavorito] = useState(false); // La verdad confirmada
  const [vista, setVista] = useOptimistic(favorito); // Lo que se enseña
  const [error, setError] = useState<string | null>(null);
  const [fallar, setFallar] = useState(false);
  const [, iniciar] = useTransition();

  function alternar() {
    iniciar(async () => {
      setError(null);
      setVista(!favorito); // Se ve al momento
      try {
        const confirmado = await guardarFavorito(!favorito, fallar);
        iniciar(() => setFavorito(confirmado));
      } catch (e) {
        iniciar(() => setError((e as Error).message));
      }
    });
  }

  return (
    <section className="tarjeta">
      <h2>Portátil UltraBook 14</h2>
      <button onClick={alternar} aria-pressed={vista}>
        {vista ? '★ En favoritos' : '☆ Añadir a favoritos'}
      </button>
      <label className="nota">
        <input type="checkbox" checked={fallar} onChange={(e) => setFallar(e.target.checked)} />{' '}
        Simular un fallo del servidor
      </label>
      <p className="nota">Confirmado por el servidor: {favorito ? 'sí' : 'no'}</p>
      {error && <p className="error" role="alert">{error}</p>}
    </section>
  );
}

Con la opción de fallo marcada, tras pulsar en un producto que ya es favorito ocurre lo siguiente:

Momento Botón Confirmado
100 ms después de pulsar ☆ Añadir a favoritos sí
Al terminar, 1 s después ★ En favoritos y mensaje de error sí

El usuario ve el error y el botón vuelve a su sitio, sin una línea de código para deshacerlo.

Listas y reductores

Con una lista no basta con sustituir el valor: quieres añadir un elemento a lo que haya. Para eso sirve el segundo argumento, una función pura que recibe el estado actual y lo que pasas a aplicar. El truco para indicar qué está pendiente es marcar el elemento provisional con un campo propio, aquí enviando:

// Ejemplo e09: src/App.tsx
import { startTransition, useOptimistic, useRef, useState } from 'react';
import { espera } from './api';

interface Nota {
  id: string;
  texto: string;
  enviando?: boolean;
}

async function guardarNota(texto: string): Promise<Nota> {
  await espera(1500);
  if (texto.includes('error')) throw new Error(`No se pudo guardar "${texto}"`);
  return { id: crypto.randomUUID(), texto };
}

export default function NotasPedido() {
  const [notas, setNotas] = useState<Nota[]>([{ id: 'n1', texto: 'Llamar al cliente' }]);
  const [error, setError] = useState<string | null>(null);
  const formulario = useRef<HTMLFormElement>(null);

  // El reductor recibe la lista actual y la nota nueva; debe ser una función pura
  const [vista, anadirVista] = useOptimistic(notas, (actual, nueva: Nota) => [...actual, nueva]);

  async function enviar(datos: FormData) {
    const texto = String(datos.get('texto')).trim();
    if (!texto) return;
    setError(null);
    anadirVista({ id: crypto.randomUUID(), texto, enviando: true });
    formulario.current?.reset(); // Vaciar ya, sin esperar al final de la Action
    try {
      const guardada = await guardarNota(texto);
      startTransition(() => setNotas((actuales) => [...actuales, guardada]));
    } catch (e) {
      startTransition(() => setError((e as Error).message));
    }
  }

  return (
    <section className="tarjeta">
      <h2>Notas del pedido 5001</h2>
      <form ref={formulario} action={enviar} className="fila">
        <input name="texto" placeholder="Escribe una nota" aria-label="Nota" />
        <button>Añadir</button>
      </form>
      {error && <p className="error" role="alert">{error}</p>}
      <ul className="lista">
        {vista.map((nota) => (
          <li key={nota.id} className={nota.enviando ? 'pendiente' : undefined}>
            {nota.texto}
            {nota.enviando && <small>enviando...</small>}
          </li>
        ))}
      </ul>
    </section>
  );
}

Lista de notas con una nota en cursiva y atenuada marcada como enviando

Figura 9. La nota aparece al momento, atenuada hasta que el servidor responde.

Prueba a enviar dos notas seguidas. Como <form action> no pone en cola, se guardan en paralelo y las dos aparecen como pendientes. El reductor es el que hace esto posible: React lo vuelve a aplicar sobre el valor real cada vez que este cambia. Lo comprobamos con un valor real de 10, un +1 optimista y un cambio externo del real a 100 a mitad de la Action:

   5 ms  vista=11
 121 ms  real=100, vista=101
 507 ms  vista=100

El +1 no se perdió al cambiar la base, porque es una operación y no un valor fijo. Por eso, cuando el valor real pueda cambiar durante la Action, usa reductor o función actualizadora en vez de setVista(11).

⚠️ Importante: Fuera de una Action, el valor optimista no sobrevive. En la prueba, llamar a setVista(5) desde un onClick normal lo mostró apenas unos milisegundos y volvió a 0, con este error en consola: “An optimistic state update occurred outside a transition or action. To fix, move the update to an action, or wrap with startTransition.”

Sin await antes del valor optimista

Pon la llamada a setVista o aplicar al principio, antes del primer await. Es lo que el usuario debe ver en cuanto pulsa. Y si combinas useOptimistic con useActionState, llama a aplicar dentro de la función de la acción, como hará el ejemplo de la sección 8. Así el valor provisional dura lo mismo que la acción.


6. requestFormReset: decidir cuándo se vacía 🆕 19.0

🆕 Desde React 19.0. No existe en React 18.3 ni anteriores. Si usas una versión anterior: llama a formulario.reset() del DOM cuando termine la operación.

El reinicio automático de <form action> es cómodo, pero tiene dos pegas que ya hemos visto: ocurre también si la operación falla, y ocurre al final, aunque el usuario siga escribiendo. En e09, una vez vaciado el campo, escribimos Texto a medias mientras se guardaba la nota anterior. Al terminar la Action, el campo volvió a quedar vacío, y lo escrito se perdió.

La solución a ambas es la misma: usar onSubmit, que no se reinicia solo, y decidir tú. En un inicio de sesión tiene mucho sentido: si la contraseña es incorrecta, quieres conservar el correo. requestFormReset, de react-dom, pide a React que reinicie un formulario junto con el resto de actualizaciones de la Action:

// Ejemplo e10: src/App.tsx
import { startTransition, useActionState } from 'react';
import type { SubmitEvent } from 'react';
import { requestFormReset } from 'react-dom';
import { ErrorApi, iniciarSesion } from './api';

interface Sesion {
  usuario: string | null;
  error: string | null;
}

interface Intento {
  formulario: HTMLFormElement;
  datos: FormData;
}

async function entrar(_anterior: Sesion, { formulario, datos }: Intento): Promise<Sesion> {
  try {
    const sesion = await iniciarSesion(String(datos.get('email')), String(datos.get('password')));
    startTransition(() => requestFormReset(formulario)); // Solo si ha ido bien
    return { usuario: sesion.usuario.nombre, error: null };
  } catch (e) {
    if (e instanceof ErrorApi) return { usuario: null, error: e.message };
    throw e;
  }
}

export default function Entrada() {
  const [{ usuario, error }, despachar, entrando] = useActionState(entrar, {
    usuario: null,
    error: null,
  });

  function enviar(evento: SubmitEvent<HTMLFormElement>) {
    evento.preventDefault(); // Sin action no hay reinicio automático
    const formulario = evento.currentTarget;
    const datos = new FormData(formulario);
    startTransition(() => despachar({ formulario, datos }));
  }

  return (
    <form onSubmit={enviar} className="tarjeta">
      <h2>Iniciar sesión</h2>
      <label>
        Correo
        <input name="email" type="email" required autoComplete="username" />
      </label>
      <label>
        Contraseña
        <input name="password" type="password" required autoComplete="current-password" />
      </label>
      <button disabled={entrando}>{entrando ? 'Entrando...' : 'Entrar'}</button>
      {error && <p className="error" role="alert">{error}</p>}
      {usuario && <p className="ok" role="status">Bienvenido, {usuario}</p>}
    </form>
  );
}
Intento Mensaje Correo tras la respuesta
Contraseña incorrecta Email o contraseña incorrectos Se conserva
Contraseña correcta Bienvenido, Ana García Vacío

Fíjate en cuatro detalles del código:

  1. El FormData se lee en enviar, antes de nada. Como viste en el módulo 08, currentTarget no sobrevive a un await.
  2. SubmitEvent es el tipo actual: @types/react 19.3.0 marca FormEvent como obsoleto.
  3. Con onSubmit no hay <form action>, así que useFormStatus no serviría. Usamos el entrando del hook.
  4. requestFormReset va en otro startTransition, porque está después de un await.

⚠️ Importante: requestFormReset exige estar en una transición. Fuera de ella, también si solo está tras un await, el formulario se reinicia, pero aparece este error en consola: “requestFormReset was called outside a transition or action. To fix, move to an action, or wrap with startTransition.”

¿Por qué no llamar simplemente a formulario.reset()? Porque se ejecuta en el momento y borra los valores antes de que React pinte el resultado. requestFormReset espera al commit de la Action, de modo que los campos y los mensajes cambian a la vez. formulario.reset() sí tiene sentido cuando quieres vaciar ya, al principio, como en este arreglo de e09, que no pierde lo que se escribe después:

// Ejemplo e09b: src/App.tsx
import { startTransition, useOptimistic, useState } from 'react';
import type { SubmitEvent } from 'react';
import { espera } from './api';

export default function NotasSinPerderTexto() {
  const [notas, setNotas] = useState<string[]>([]);
  const [vista, anadirVista] = useOptimistic(notas, (actual, nueva: string) => [...actual, nueva]);

  function enviar(evento: SubmitEvent<HTMLFormElement>) {
    evento.preventDefault();
    const formulario = evento.currentTarget;
    const texto = String(new FormData(formulario).get('texto'));
    formulario.reset(); // Se vacía una sola vez, ahora, y nada lo vuelve a tocar
    startTransition(async () => {
      anadirVista(texto);
      await espera(1500);
      startTransition(() => setNotas((actuales) => [...actuales, texto]));
    });
  }

  return (
    <section className="tarjeta">
      <form onSubmit={enviar} className="fila">
        <input name="texto" aria-label="Nota" />
        <button>Añadir</button>
      </form>
      <ul className="lista">
        {vista.map((nota) => <li key={nota}>{nota}</li>)}
      </ul>
    </section>
  );
}

Con esta versión, el campo conserva Texto a medias al terminar la Action.

Quieres Usa
Vaciar siempre al terminar <form action>, sin hacer nada
Conservar lo escrito tras un error <form action> y devolver valores para defaultValue
Vaciar solo si va bien onSubmit y requestFormReset
Vaciar al enviar y seguir escribiendo onSubmit y formulario.reset() al principio
Reiniciar un formulario controlado Asignar los valores iniciales con setState; el reinicio no afecta a estos campos

7. Errores: devolver o lanzar

Hay dos tipos de error, y confundirlos es la causa de muchos problemas:

  Esperado Inesperado
Ejemplos Validación 422, conflicto 409, no encontrado 404 Error 500, red caída, un fallo en tu código
¿Puede arreglarlo el usuario? Sí, cambiando algo No
Qué haces Devolverlo como estado Lanzarlo
Dónde se ve Junto al formulario En un Error Boundary
La cola de useActionState Sigue Se cancela

La función del ejemplo atrapa lo que sabe tratar y vuelve a lanzar el resto:

// Ejemplo e11: src/App.tsx
import { Component, useActionState, useState } from 'react';
import type { ReactNode } from 'react';
import { ErrorApi, escribir } from './api';

class BarreraErrores extends Component<
  { alReintentar: () => void; children: ReactNode },
  { error: Error | null }
> {
  state = { error: null as Error | null };

  static getDerivedStateFromError(error: Error) {
    return { error };
  }

  render() {
    if (!this.state.error) return this.props.children;
    return (
      <section className="tarjeta" role="alert">
        <h2>Algo ha ido mal</h2>
        <p className="error">{this.state.error.message}</p>
        <button onClick={this.props.alReintentar}>Reintentar</button>
      </section>
    );
  }
}

async function cambiarEstado(_anterior: string | null, datos: FormData): Promise<string | null> {
  try {
    await escribir('PATCH', `/pedidos/${datos.get('pedido')}`, { estado: datos.get('estado') });
    return null;
  } catch (e) {
    // Esperado: el usuario puede corregirlo. Se devuelve como estado
    if (e instanceof ErrorApi && [404, 409, 422].includes(e.status)) return e.message;
    // Inesperado: no sabemos seguir. Se lanza y aparece la barrera
    throw e;
  }
}

function FormularioPedido() {
  const [mensaje, accion, enviando] = useActionState(cambiarEstado, null);

  return (
    <form action={accion} className="tarjeta">
      <h2>Cambiar estado</h2>
      <div className="fila">
        <select name="pedido" aria-label="Pedido" defaultValue="5001">
          <option>5001</option>
          <option>9999</option>
        </select>
        <select name="estado" aria-label="Estado" defaultValue="ENVIADO">
          <option>ENVIADO</option>
          <option>EXTRAVIADO</option>
        </select>
        <button disabled={enviando}>Guardar</button>
      </div>
      {mensaje && <p className="error">{mensaje}</p>}
    </form>
  );
}

export default function App() {
  const [intento, setIntento] = useState(0);

  return (
    <BarreraErrores key={intento} alReintentar={() => setIntento((n) => n + 1)}>
      <FormularioPedido />
    </BarreraErrores>
  );
}

Elegir el pedido 9999 y guardar da un 404, que se muestra en el formulario: Pedido 9999 no encontrado. Si la API falla de verdad, la acción lanza la excepción y aparece la barrera. Puedes provocarlo deteniendo la API; nosotros simulamos un 500:

Tarjeta con el título Algo ha ido mal, el mensaje del error 500 y un botón Reintentar

Figura 10. Un error inesperado sustituye al formulario por la barrera.

Reintentar incrementa intento, que es la key de la barrera: React crea de nuevo la barrera y el formulario, y con ellos un useActionState limpio. El estado anterior se pierde, y es lo razonable: tras un error inesperado no sabemos si era válido. Los Error Boundaries siguen siendo clases, como viste en el módulo 15.

Qué pasa con la cola

Una excepción cancela lo que quedaba pendiente. Despachamos 1, 99 y 3, con una acción que lanza una excepción al recibir 99:

empieza 1, termina 1
empieza 99, termina 99 lanzando una excepción
capturado por el Error Boundary

La acción 3 nunca empezó. Si no quieres perderla, atrapa el error dentro de la función y devuélvelo.

💡 Consejo: Atrapa solo lo que sabes tratar. Un catch que convierte cualquier cosa en un mensaje genérico oculta también los errores de programación: un TypeError en tu código se mostraría como “No se ha podido guardar” y pasarías horas buscándolo. Por eso los ejemplos comprueban e instanceof ErrorApi y dejan pasar el resto.


8. Caso completo: departamentos con la API EMPRESA

Vamos a juntar las piezas. Esta pantalla lista los departamentos, permite crearlos y borrarlos, y todo responde al instante. Los datos se cargan con use(promesa) y Suspense, como en el módulo 15:

// Ejemplo e12: src/App.tsx
import { Suspense, startTransition, use, useActionState, useOptimistic, useState, useTransition } from 'react';
import { useFormStatus } from 'react-dom';
import { ErrorApi, escribir, leer } from './api';
import type { Departamento, Pagina } from './api';

// ---------- Datos ----------

function cargarDepartamentos() {
  return leer<Pagina<Departamento>>('/departamentos?orden=deptId&tamano=100').then((p) => p.contenido);
}

type Cambio =
  | { tipo: 'alta'; departamento: Departamento }
  | { tipo: 'baja'; deptId: number };

function aplicarCambio(lista: Departamento[], cambio: Cambio): Departamento[] {
  switch (cambio.tipo) {
    case 'alta':
      return [...lista, cambio.departamento];
    case 'baja':
      return lista.filter((d) => d.deptId !== cambio.deptId);
  }
}

// ---------- Componentes ----------

function BotonCrear() {
  const { pending } = useFormStatus();
  return <button disabled={pending}>{pending ? 'Creando...' : 'Crear'}</button>;
}

interface ListaProps {
  promesa: Promise<Departamento[]>;
  recargar: () => Promise<void>;
}

function Lista({ promesa, recargar }: ListaProps) {
  const departamentos = use(promesa);
  const [vista, aplicar] = useOptimistic(departamentos, aplicarCambio);
  const [errorBaja, setErrorBaja] = useState<string | null>(null);
  const [, iniciar] = useTransition();

  async function crear(_anterior: string | null, datos: FormData): Promise<string | null> {
    const nombre = String(datos.get('nombre')).trim();
    const localidad = String(datos.get('localidad')).trim();
    // Identificador negativo: nunca choca con los reales y marca la fila como pendiente
    aplicar({ tipo: 'alta', departamento: { deptId: -Date.now(), nombre, localidad, presupuesto: null } });
    try {
      await escribir('POST', '/departamentos', { nombre, localidad });
      await recargar();
      return null;
    } catch (e) {
      if (e instanceof ErrorApi) return e.detalles.nombre ?? e.message;
      throw e;
    }
  }

  const [errorAlta, accionCrear] = useActionState(crear, null);

  function borrar(deptId: number) {
    iniciar(async () => {
      setErrorBaja(null);
      aplicar({ tipo: 'baja', deptId });
      try {
        await escribir('DELETE', `/departamentos/${deptId}`);
        await recargar();
      } catch (e) {
        if (!(e instanceof ErrorApi)) throw e;
        iniciar(() => setErrorBaja(e.message));
      }
    });
  }

  return (
    <>
      <form action={accionCrear} className="fila">
        <input name="nombre" placeholder="Nombre" aria-label="Nombre" />
        <input name="localidad" placeholder="Localidad" aria-label="Localidad" />
        <BotonCrear />
      </form>
      {errorAlta && <p className="error" role="alert">{errorAlta}</p>}
      {errorBaja && <p className="error" role="alert">{errorBaja}</p>}

      <ul className="lista">
        {vista.map((d) => (
          <li key={d.deptId} className={d.deptId < 0 ? 'pendiente' : undefined}>
            <span>{d.nombre} ({d.localidad})</span>
            {d.deptId > 0 && (
              <button
                className="secundario"
                onClick={() => borrar(d.deptId)}
                aria-label={`Borrar ${d.nombre}`}
              >
                Borrar
              </button>
            )}
          </li>
        ))}
      </ul>
    </>
  );
}

export default function PanelDepartamentos() {
  const [promesa, setPromesa] = useState(cargarDepartamentos);

  async function recargar() {
    const nueva = cargarDepartamentos();
    await nueva; // Los datos ya están aquí: el cambio no volverá a suspender la lista
    startTransition(() => setPromesa(nueva));
  }

  return (
    <section className="tarjeta">
      <h2>Departamentos</h2>
      <Suspense fallback={<p className="nota">Cargando departamentos...</p>}>
        <Lista promesa={promesa} recargar={recargar} />
      </Suspense>
    </section>
  );
}

Lista de departamentos con una fila nueva atenuada, sin botón de borrar, y el botón Creando

Figura 11. El alta aparece al momento, con un identificador provisional negativo.

Vale la pena repasar qué hace cada pieza:

Pieza Para qué
use(promesa) y Suspense Primera carga
useActionState Alta: resultado, error por campo y cola
useFormStatus Botón Crear, independiente del resto
useOptimistic con reductor Altas y bajas visibles al momento, con un único tipo Cambio
useTransition Bajas, que pueden ir en paralelo
recargar Sustituir los datos sin suspender la lista

Lo más delicado es recargar. Si cambiases la promesa nada más crearla, la Action terminaría antes de que llegasen los datos, el valor optimista desaparecería y la fila parpadearía. Con await nueva, la Action no acaba hasta tener la lista nueva, y como la promesa ya está resuelta, cambiarla no muestra el fallback. En la prueba contamos las filas durante un alta: 8 desde el clic hasta el final, sin ningún salto. Un alta rechazada, en cambio, sube a 9 durante la espera y baja a 8 cuando llega el error.

Borrar VENTAS también muestra la corrección automática: la fila desaparece, la API responde 409 porque tiene empleados, y vuelve a aparecer con el mensaje de error.

Lista de departamentos completa con dos mensajes de error sobre la lista

Figura 12. El borrado rechazado devuelve la fila a su sitio sin código para deshacerlo.

💡 Consejo: En una aplicación real, la recarga y la caché suelen ser tarea de una biblioteca. Con TanStack Query, en el módulo 19, recargar será una invalidación. Y si navegas con React Router, sus action del módulo 18 recargan los datos solos.


9. Qué herramienta usar

Estas piezas se combinan, no compiten. Elige partiendo de la operación:

Árbol de preguntas que lleva a useTransition, useActionState, useFormStatus y useOptimistic

Figura 13. Las dos primeras preguntas eligen la base. Las otras dos añaden piezas.

Situación Recomendación
Botón que guarda algo y solo necesita “Guardando…” useTransition
Formulario con errores de servidor useActionState
Varias filas con su propia operación en paralelo Un useTransition por fila, en un componente propio
Cesta u otra operación cuyo orden importa useActionState, por su cola
Botón de envío reutilizable useFormStatus
“Me gusta”, marcar como leído, reordenar useOptimistic
Pago u operación irreversible Sin optimismo: espera la confirmación
Validación compleja, muchos campos, formularios dinámicos React Hook Form, en el módulo 21
Mucha lectura con caché compartida TanStack Query, en el módulo 19
Lógica que debe ejecutarse en el servidor Server Functions, en el módulo 23

📘 Concepto: Las Actions brillan especialmente en los Server Components, donde <form action> llama a una función del servidor y funciona antes de cargar el JavaScript. Lo que aprendes aquí es lo mismo que usarás allí, con la diferencia de dónde se ejecuta la función.


10. Errores frecuentes y depuración

isPending no cambia nunca. Has llamado a despachar fuera de una transición. Mira la consola: verás “An async function with useActionState was called outside of a transition”. Envuélvela en startTransition o pásala a action.

El dato cambia antes de que acabe la espera. Hay un setState después de un await sin envolver. Envuélvelo o devuelve el valor desde useActionState.

La función recibe datos raros. Con useActionState, el primer parámetro es el estado anterior y el FormData llega en el segundo. Si escribes async function crear(datos: FormData), datos será el estado y datos.get fallará. TypeScript lo detecta.

El botón no se desactiva con useFormStatus. Comprueba dos cosas: que el botón sea un componente hijo del <form>, y que el formulario use action y no onSubmit.

El formulario se vacía tras un error. Es el reinicio automático. Devuelve los valores y úsalos en defaultValue, o pasa a onSubmit.

El select vuelve a su valor anterior. Es la limitación de la sección 3. Añádele una key que dependa del valor o hazlo controlado.

El usuario pierde lo que escribía durante el envío. También es el reinicio automático, que ocurre al final. Usa onSubmit y vacía el campo al principio.

Las acciones dejan de ejecutarse. Una de ellas ha lanzado una excepción y se ha cancelado la cola. Atrapa los errores esperados y devuélvelos.

El resultado de una acción rápida tarda. Se está publicando junto a otra Action más lenta. React las agrupa, como viste en la sección 2.

El valor optimista parpadea. La Action termina antes de que llegue el valor real. Asegúrate de actualizar ese valor dentro de la Action, tras un await, y de que este alcance a la operación completa, como en recargar.

Se pierden actualizaciones al pulsar muy deprisa. Si la Action calcula el valor nuevo con una variable capturada, como stock + ajuste, dos clics seguidos parten del mismo número. Envía la operación y deja que el servidor calcule, o usa una función actualizadora.

“Cannot update action state while rendering”. Has llamado a despachar durante el render. Llámala solo desde eventos o Actions.

Para depurar, pon un console.log al principio y al final de la función de la acción, como en e05: verás el orden real. La pestaña Network confirma qué peticiones salieron, y las Performance Tracks del módulo 14 muestran las transiciones. Con RETARDO=2000 los estados intermedios son fáciles de observar.


11. Ejercicios

Todos usan src/api.ts y src/index.css de la sección 1.

Ejercicio 1: Marcar pedidos como enviados

Lista los pedidos con estado PENDIENTE (GET /pedidos?estado=PENDIENTE&tamano=5). Cada fila tiene un botón que hace PATCH /pedidos/:id con { estado: 'ENVIADO' }. Solo debe mostrar “Enviando…” la fila pulsada, y se debe poder enviar varias filas a la vez.

Ver solución
import { useEffect, useState, useTransition } from 'react';
import { escribir, leer } from './api';
import type { Pagina } from './api';

interface Pedido {
  pedidoId: number;
  cliente: string;
  estado: string;
}

function FilaPedido({ pedido }: { pedido: Pedido }) {
  const [estado, setEstado] = useState(pedido.estado);
  const [error, setError] = useState<string | null>(null);
  const [enviando, iniciar] = useTransition(); // Una transición por fila

  function marcarEnviado() {
    iniciar(async () => {
      setError(null);
      try {
        const actualizado = await escribir<Pedido>('PATCH', `/pedidos/${pedido.pedidoId}`, {
          estado: 'ENVIADO',
        });
        iniciar(() => setEstado(actualizado.estado));
      } catch (e) {
        iniciar(() => setError((e as Error).message));
      }
    });
  }

  return (
    <li>
      <span>{pedido.pedidoId} · {pedido.cliente} · {estado}</span>
      {error && <span className="error">{error}</span>}
      <button onClick={marcarEnviado} disabled={enviando || estado !== 'PENDIENTE'}>
        {enviando ? 'Enviando...' : 'Marcar enviado'}
      </button>
    </li>
  );
}

export default function PedidosPendientes() {
  const [pedidos, setPedidos] = useState<Pedido[]>([]);

  useEffect(() => {
    leer<Pagina<Pedido>>('/pedidos?estado=PENDIENTE&tamano=5').then((p) => setPedidos(p.contenido));
  }, []);

  return (
    <section className="tarjeta">
      <h2>Pedidos pendientes</h2>
      <ul className="lista">
        {pedidos.map((p) => <FilaPedido key={p.pedidoId} pedido={p} />)}
      </ul>
    </section>
  );
}

La clave es dónde está useTransition. Si estuviera en la lista, habría un único isPending para todas las filas. Al ponerlo en FilaPedido, cada fila tiene el suyo. Al pulsar la primera, esa muestra Enviando... y la segunda sigue en Marcar enviado. useActionState no sería buena opción, porque su cola haría que se enviasen de una en una.

Ejercicio 2: Alta de producto

Crea un formulario de producto con nombre y precio que haga POST /productos con categoriaId: 1 y stock: 0. Muestra los errores 422 de cada campo sin perder lo escrito y, si va bien, vacía el formulario y muestra el nombre creado.

Ver solución
import { useActionState } from 'react';
import { ErrorApi, escribir } from './api';

interface Estado {
  nombre: string;
  precio: string;
  errores: Record<string, string>;
  creado: string | null;
}

const INICIAL: Estado = { nombre: '', precio: '', errores: {}, creado: null };

async function crearProducto(_anterior: Estado, datos: FormData): Promise<Estado> {
  const nombre = String(datos.get('nombre'));
  const precio = String(datos.get('precio'));

  try {
    const creado = await escribir<{ nombre: string }>('POST', '/productos', {
      nombre,
      precio: precio === '' ? null : Number(precio),
      categoriaId: 1,
      stock: 0,
    });
    return { ...INICIAL, creado: creado.nombre };
  } catch (e) {
    if (e instanceof ErrorApi && e.status === 422) {
      return { nombre, precio, errores: e.detalles, creado: null };
    }
    throw e;
  }
}

export default function AltaProducto() {
  const [estado, accion, enviando] = useActionState(crearProducto, INICIAL);

  return (
    <form action={accion} className="tarjeta">
      <h2>Nuevo producto</h2>
      <label>
        Nombre
        <input name="nombre" defaultValue={estado.nombre} />
        {estado.errores.nombre && <span className="error">{estado.errores.nombre}</span>}
      </label>
      <label>
        Precio
        <input name="precio" type="number" step="0.01" defaultValue={estado.precio} />
        {estado.errores.precio && <span className="error">{estado.errores.precio}</span>}
      </label>
      <button disabled={enviando}>{enviando ? 'Creando...' : 'Crear'}</button>
      {estado.creado && <p className="ok">Creado: {estado.creado}</p>}
    </form>
  );
}

Con el formulario vacío, la API responde El nombre es obligatorio y El precio no puede ser negativo. Con Teclado mecánico y 59.9, el resultado es Creado: Teclado mecánico. El reinicio automático deja vacíos los campos, porque el estado devuelto trae valores vacíos.

Ejercicio 3: Botón de envío genérico

Crea BotonEnvio, un botón que admita todas las props de <button>, más textoEnviando. Debe desactivarse solo durante el envío y anunciarlo con aria-busy. Crea también ResumenEnvio, que indique cuántos ficheros se están subiendo. Úsalos en un formulario con título y fichero.

Ver solución
import { useState } from 'react';
import type { ComponentProps } from 'react';
import { useFormStatus } from 'react-dom';
import { espera } from './api';

interface BotonProps extends ComponentProps<'button'> {
  textoEnviando?: string;
}

function BotonEnvio({ textoEnviando = 'Enviando...', children, disabled, ...resto }: BotonProps) {
  const { pending } = useFormStatus();
  return (
    <button {...resto} disabled={disabled || pending} aria-busy={pending}>
      {pending ? textoEnviando : children}
    </button>
  );
}

function ResumenEnvio() {
  const { pending, data } = useFormStatus();
  if (!pending) return null;
  return <p className="nota">Subiendo {data.getAll('adjunto').length} fichero(s)...</p>;
}

export default function Adjuntos() {
  const [mensaje, setMensaje] = useState('');

  async function subir(datos: FormData) {
    await espera(1000);
    setMensaje(`Recibido: ${datos.get('titulo')}`);
  }

  return (
    <form action={subir} className="tarjeta">
      <h2>Adjuntar documento</h2>
      <label>
        Título
        <input name="titulo" required />
      </label>
      <label>
        Fichero
        <input name="adjunto" type="file" />
      </label>
      <BotonEnvio textoEnviando="Subiendo...">Subir</BotonEnvio>
      <ResumenEnvio />
      {mensaje && <p className="ok">{mensaje}</p>}
    </form>
  );
}

Al enviar, el botón muestra Subiendo... y el resumen dice Subiendo 1 fichero(s).... Un campo de fichero sin seleccionar también aparece en el FormData, como un fichero vacío. ComponentProps<'button'>, del módulo 13, da todas las props del elemento, y disabled || pending respeta un disabled recibido.

Ejercicio 4: Línea de cesta con órdenes

Crea una línea de cesta con botones +, - y Vaciar sobre un mismo useActionState, con una unión discriminada de órdenes. Cada orden tarda 300 ms. El stock es de 3 unidades: pasar de ahí no cambia la cantidad y muestra un error. Comprueba qué pasa al pulsar cinco veces + y una - muy deprisa.

Ver solución
import { useActionState, useTransition } from 'react';
import { espera } from './api';

type Orden = { tipo: 'sumar' } | { tipo: 'restar' } | { tipo: 'vaciar' };

interface Linea {
  unidades: number;
  error: string | null;
}

const STOCK = 3;

async function actualizar(actual: Linea, orden: Orden): Promise<Linea> {
  await espera(300); // Aquí iría la llamada al servidor
  switch (orden.tipo) {
    case 'sumar':
      return actual.unidades >= STOCK
        ? { ...actual, error: `Solo quedan ${STOCK} unidades` }
        : { unidades: actual.unidades + 1, error: null };
    case 'restar':
      return { unidades: Math.max(0, actual.unidades - 1), error: null };
    case 'vaciar':
      return { unidades: 0, error: null };
  }
}

export default function LineaCesta() {
  const [linea, despachar, ocupado] = useActionState(actualizar, { unidades: 0, error: null });
  const [, iniciar] = useTransition();

  const enviar = (orden: Orden) => iniciar(() => despachar(orden));

  return (
    <section className="tarjeta">
      <h2>Ratón inalámbrico</h2>
      <div className="fila">
        <button onClick={() => enviar({ tipo: 'restar' })}>-</button>
        <output aria-live="polite">{linea.unidades}</output>
        <button onClick={() => enviar({ tipo: 'sumar' })}>+</button>
        <button className="secundario" onClick={() => enviar({ tipo: 'vaciar' })}>Vaciar</button>
        {ocupado && <small>actualizando...</small>}
      </div>
      {linea.error && <p className="error">{linea.error}</p>}
    </section>
  );
}

Las seis órdenes se ponen en cola y se ejecutan en orden: 1, 2, 3, error, error y 2. La pantalla sigue en 0 con actualizando... hasta que se vacía la cola y entonces muestra 2. Como - no conserva el error, el mensaje desaparece. Fíjate en que la comprobación del stock mira actual.unidades, que siempre es el resultado anterior, y por eso nunca se usa un valor desfasado. Devolver el error, en lugar de lanzarlo, es lo que permite que la cola continúe.

Ejercicio 5: Ajuste optimista de stock

Crea botones -1, +1 y +5 que ajusten el stock de un producto en un servidor simulado que tarda 800 ms y rechaza superar 12 unidades. La pantalla debe reflejar el ajuste al instante y acumular varios clics. Si el servidor rechaza, debe volver al valor real y mostrar el error.

Ver solución
import { useOptimistic, useState, useTransition } from 'react';
import { espera } from './api';

// Simula el servidor: guarda su propio stock y rechaza superar 12 unidades
let stockServidor = 10;

async function guardarAjuste(ajuste: number): Promise<number> {
  await espera(800);
  if (stockServidor + ajuste > 12) throw new Error('El almacén solo admite 12 unidades');
  stockServidor += ajuste;
  return stockServidor;
}

export default function AjusteStock() {
  const [stock, setStock] = useState(stockServidor);
  const [vista, ajustarVista] = useOptimistic(stock, (actual, ajuste: number) => actual + ajuste);
  const [error, setError] = useState<string | null>(null);
  const [ocupado, iniciar] = useTransition();

  function ajustar(ajuste: number) {
    iniciar(async () => {
      setError(null);
      ajustarVista(ajuste);
      try {
        const confirmado = await guardarAjuste(ajuste);
        iniciar(() => setStock(confirmado));
      } catch (e) {
        iniciar(() => setError((e as Error).message));
      }
    });
  }

  return (
    <section className="tarjeta">
      <h2>Stock de Portátil UltraBook 14</h2>
      <div className="fila">
        <button onClick={() => ajustar(-1)}>-1</button>
        <strong id="vista">{vista}</strong>
        <button onClick={() => ajustar(1)}>+1</button>
        <button onClick={() => ajustar(5)}>+5</button>
      </div>
      <p className="nota">Confirmado: {stock} {ocupado && '(guardando...)'}</p>
      {error && <p className="error" role="alert">{error}</p>}
    </section>
  );
}

Partiendo de 10, dos clics rápidos en +1 muestran 12 con Confirmado: 10 (guardando...), y terminan en 12 confirmado. Después, +5 muestra 17 y vuelve a 12 con el error.

Observa que guardarAjuste recibe el ajuste, no el total. En la primera versión de esta solución enviábamos stock + ajuste: los dos clics capturaban stock = 10, ambos calculaban 11 y uno de los incrementos se perdía. La vista optimista no lo revelaba, porque su reductor sí acumula bien. Enviar la operación deja el cálculo en manos del servidor.

Ejercicio 6: Bajas optimistas de empleados

Muestra los seis primeros empleados con un botón Borrar (DELETE /empleados/:id). La fila debe desaparecer al momento. Si la API lo rechaza, debe volver y mostrar el motivo. Ana García (100) no se puede borrar porque tiene subordinados.

Ver solución
import { useEffect, useOptimistic, useState, useTransition } from 'react';
import { ErrorApi, escribir, leer } from './api';
import type { Pagina } from './api';

interface Empleado {
  empId: number;
  nombre: string;
  apellidos: string;
}

export default function BajasEmpleados() {
  const [empleados, setEmpleados] = useState<Empleado[]>([]);
  const [vista, quitarVista] = useOptimistic(empleados, (lista, empId: number) =>
    lista.filter((e) => e.empId !== empId),
  );
  const [error, setError] = useState<string | null>(null);
  const [, iniciar] = useTransition();

  useEffect(() => {
    leer<Pagina<Empleado>>('/empleados?orden=empId&tamano=6').then((p) => setEmpleados(p.contenido));
  }, []);

  function borrar(empleado: Empleado) {
    iniciar(async () => {
      setError(null);
      quitarVista(empleado.empId);
      try {
        await escribir('DELETE', `/empleados/${empleado.empId}`);
        iniciar(() => setEmpleados((lista) => lista.filter((e) => e.empId !== empleado.empId)));
      } catch (e) {
        if (!(e instanceof ErrorApi)) throw e;
        iniciar(() => setError(`${empleado.nombre}: ${e.message}`));
      }
    });
  }

  return (
    <section className="tarjeta">
      <h2>Empleados</h2>
      {error && <p className="error" role="alert">{error}</p>}
      <ul className="lista">
        {vista.map((e) => (
          <li key={e.empId}>
            {e.nombre} {e.apellidos}
            <button className="secundario" onClick={() => borrar(e)}>Borrar</button>
          </li>
        ))}
      </ul>
    </section>
  );
}

Al pulsar en Ana, la lista baja a 5 filas y, cuando llega el 409, vuelve a 6 con Ana: No se puede borrar a un empleado que tiene subordinados. El if (!(e instanceof ErrorApi)) throw e deja pasar los errores inesperados al Error Boundary, y por eso usamos iniciar del hook y no startTransition del paquete.

Ejercicio 7: Alta en serie

Crea un formulario de alta de departamentos pensado para dar de alta varios seguidos. Si la API rechaza, conserva todos los campos sin escribir defaultValue para ello. Si acepta, vacía el formulario y aumenta un contador de creados.

Ver solución
import { startTransition, useActionState } from 'react';
import type { SubmitEvent } from 'react';
import { requestFormReset } from 'react-dom';
import { ErrorApi, escribir } from './api';

interface Resultado {
  error: string | null;
  creados: number;
}

interface Envio {
  formulario: HTMLFormElement;
  datos: FormData;
}

async function crear(anterior: Resultado, { formulario, datos }: Envio): Promise<Resultado> {
  try {
    await escribir('POST', '/departamentos', {
      nombre: datos.get('nombre'),
      localidad: datos.get('localidad'),
    });
    startTransition(() => requestFormReset(formulario));
    return { error: null, creados: anterior.creados + 1 };
  } catch (e) {
    if (e instanceof ErrorApi) return { ...anterior, error: e.detalles.nombre ?? e.message };
    throw e;
  }
}

export default function AltaEnSerie() {
  const [{ error, creados }, despachar, enviando] = useActionState(crear, { error: null, creados: 0 });

  function enviar(evento: SubmitEvent<HTMLFormElement>) {
    evento.preventDefault();
    const formulario = evento.currentTarget;
    const datos = new FormData(formulario);
    startTransition(() => despachar({ formulario, datos }));
  }

  return (
    <form onSubmit={enviar} className="tarjeta">
      <h2>Alta de departamentos en serie</h2>
      <label>
        Nombre
        <input name="nombre" />
      </label>
      <label>
        Localidad
        <input name="localidad" defaultValue="Madrid" />
      </label>
      <button disabled={enviando}>{enviando ? 'Guardando...' : 'Guardar y seguir'}</button>
      {error && <p className="error" role="alert">{error}</p>}
      <p className="nota">Creados en esta sesión: {creados}</p>
    </form>
  );
}

Con VENTAS, el formulario muestra el error y conserva VENTAS y Vigo, porque onSubmit no reinicia nada. Con SERIE 1, el contador pasa a 1, el nombre queda vacío y la localidad vuelve a Madrid, que es su defaultValue. No hace falta devolver los valores en el estado, y el código queda más corto que con <form action>.

Ejercicio 8: Exportación con barrera

Crea un exportador de pedidos con un select de formato. csv funciona; pdf debe mostrar un aviso en el propio formulario; roto simula que falla el servicio y debe mostrar una barrera con un botón para volver al formulario, ya limpio.

Ver solución
import { Component, useActionState, useState } from 'react';
import type { ReactNode } from 'react';
import { espera } from './api';

class Barrera extends Component<
  { alReintentar: () => void; children: ReactNode },
  { fallo: string | null }
> {
  state = { fallo: null as string | null };

  static getDerivedStateFromError(error: Error) {
    return { fallo: error.message };
  }

  render() {
    if (this.state.fallo === null) return this.props.children;
    return (
      <section className="tarjeta" role="alert">
        <p className="error">{this.state.fallo}</p>
        <button onClick={this.props.alReintentar}>Volver al formulario</button>
      </section>
    );
  }
}

async function exportar(_anterior: string, datos: FormData): Promise<string> {
  await espera(500);
  const formato = datos.get('formato');
  if (formato === 'pdf') return 'El PDF aún no está disponible: elige CSV'; // Esperado
  if (formato === 'roto') throw new Error('El servicio de exportación no responde'); // Inesperado
  return 'Exportación CSV lista';
}

function Exportador() {
  const [mensaje, accion, ocupado] = useActionState(exportar, '');
  return (
    <form action={accion} className="tarjeta">
      <h2>Exportar pedidos</h2>
      <div className="fila">
        <select name="formato" aria-label="Formato">
          <option value="csv">CSV</option>
          <option value="pdf">PDF</option>
          <option value="roto">Formato que rompe el servicio</option>
        </select>
        <button disabled={ocupado}>Exportar</button>
      </div>
      {mensaje && <p className="nota">{mensaje}</p>}
    </form>
  );
}

export default function App() {
  const [intento, setIntento] = useState(0);
  return (
    <Barrera key={intento} alReintentar={() => setIntento((n) => n + 1)}>
      <Exportador />
    </Barrera>
  );
}

pdf devuelve el texto y aparece bajo el formulario. roto lanza la excepción, aparece El servicio de exportación no responde y Volver al formulario crea de nuevo el Exportador, porque cambia la key de la barrera. El mismo throw dentro de un <form action> llega a la barrera porque React lo vuelve a lanzar desde el hook durante el render.

Ejercicio 9 (propuesto): Comentarios de un pedido

Añade a las notas de e09 un botón para borrar cada nota, usando el mismo useOptimistic con un reductor que distinga alta y baja, como el de la sección 8.

Ejercicio 10 (propuesto): Buscador de productos sin cola

Reescribe el buscador de e06 para productos, con useTransition en lugar de useActionState, y lanza una búsqueda en cada pulsación. Tendrás que evitar tú mismo que una respuesta antigua pise a una nueva. Compara el resultado con la versión de la cola.

Ejercicio 11 (propuesto): Inicio de sesión con redirección

Amplía e10 para que, tras entrar, se guarde el token con useState en el componente padre y se muestre un panel con el usuario y un botón para salir. El botón debe usar formAction en un formulario propio.


Resumen

Concepto API Idea clave
Action startTransition, <form action>, formAction Función que React vigila de principio a fin
Espera sin estado propio useTransition 🆕 19.0 con async isPending dura hasta que acaba la función
Tras un await startTransition(() => setX()) Sin envolver, la actualización es urgente
Hook o paquete useTransition, startTransition Solo el hook lleva los errores al Error Boundary
Varias Actions Agrupadas Se publican juntas al acabar la última, también en 19.3
Resultado como estado useActionState 🆕 19.0 (anterior, carga) => nuevo; sin setState
Orden Cola de useActionState Secuencial, y la excepción cancela el resto
Fuera de transición despachar en startTransition Si no, isPending no se actualiza
Cancelar AbortController en la carga Solo en lecturas
Estado del formulario useFormStatus 🆕 19.0 Solo en hijos de un <form> con Action
Interfaz optimista useOptimistic 🆕 19.0 Se descarta al terminar; no hay nada que deshacer
Listas optimistas Reductor de useOptimistic Se reaplica cuando cambia el valor real
Reinicio a medida requestFormReset 🆕 19.0 En transición, con onSubmit, solo si va bien
Conservar lo escrito defaultValue con el estado select necesita key
Errores esperados return Se muestran junto al formulario
Errores inesperados throw Error Boundary con key para reintentar