Saltar al contenido

Acciones, formularios y actualizaciones optimistas

¿Cuántos useState hacen falta para un formulario? En React 19, ninguno.

Al terminar vas a poder explicar

  • Explicar qué maneja React por vos cuando pasás una función a la prop action
  • Usar useFormStatus desde un hijo y decir por qué no funciona en el mismo componente
  • Describir cómo useOptimistic revierte un cambio sin que vos escribas nada

Contá cuánto estado hace falta para un formulario que envía algo a un servidor: el valor de cada campo, si está enviando, el error si falló, el resultado si salió bien. Y el cableado para que todo eso se mantenga coherente.

lo que veníamos escribiendo, una y otra vez
1const [nombre, setNombre] = useState("");2const [enviando, setEnviando] = useState(false);3const [error, setError] = useState(null);4const [exito, setExito] = useState(false);5 6async function alEnviar(e) {7  e.preventDefault();8  setEnviando(true);9  setError(null);10  try {11    await guardar(nombre);12    setExito(true);13    setNombre("");14  } catch (err) {15    setError(err.message);16  } finally {17    setEnviando(false);      // el que siempre se olvida en algún camino18  }19}

Es un patrón tan repetido que React 19 lo absorbió. Se llaman Actions.

action recibe una función

La prop action de un <form> ahora acepta una función. React se encarga de llamarla con el FormData, manejar el estado pendiente, capturar el error y resetear el formulario si sale bien.

React real

useActionState y useFormStatus

React se encarga del estado pendiente, del resultado y del error. Sin tres useState cableados a mano.

Código

1const { useActionState } = React;2const { useFormStatus } = ReactDOM;3 4async function guardar(estadoPrevio, formData) {5  const nombre = formData.get("nombre");6  await new Promise(r => setTimeout(r, 900));7  if (!nombre) return { error: "El nombre no puede estar vacío." };8  return { ok: `Guardado: ${nombre}` };9}10 11function Boton() {12  // Lee el estado del <form> más cercano hacia arriba.13  const { pending } = useFormStatus();14  return <button disabled={pending}>{pending ? "guardando…" : "guardar"}</button>;15}16 17function App() {18  const [estado, accionDeFormulario] = useActionState(guardar, {});19 20  return (21    <form action={accionDeFormulario}>22      <input name="nombre" placeholder="tu nombre" />23      <Boton />24      {estado.ok && <p>{estado.ok}</p>}25      {estado.error && <p style={{ color: "#ff7b93" }}>{estado.error}</p>}26    </form>27  );28}

Corriendo de verdad

bajando React 19.2.0 de esm.sh…

Registro0 commits

Todo lo que imprimas con console.log aparece acá. Los commits los reporta el Profiler de React de verdad.

Probá enviarlo vacío y después con un nombre. El botón se deshabilita solo mientras envía, el error aparece y desaparece, y no hay un solo useState en ese código.

useActionState: el estado que devuelve la acción

1const [estado, accionEnvuelta, pendiente] = useActionState(guardar, estadoInicial);2 3async function guardar(estadoPrevio, formData) {4  const nombre = formData.get("nombre");5  if (!nombre) return { error: "El nombre no puede estar vacío." };6  await api.guardar(nombre);7  return { ok: true };8}

Fijate la firma de la acción: recibe el estado anterior y el FormData. Es un reducer asincrónico. Y como devolvés el estado en vez de setearlo desde cinco lugares, es imposible que quede a medio camino.

useFormStatus: leer desde un hijo

El botón de enviar suele ser un componente aparte, y necesita saber si el formulario está enviando. Pasarle una prop desde arriba obliga a que cada formulario cablee lo mismo.

1function BotonEnviar() {2  const { pending } = useFormStatus();     // lee el <form> de arriba3  return <button disabled={pending}>{pending ? "guardando…" : "guardar"}</button>;4}

useOptimistic: mostrar el resultado antes de que ocurra

Esperar la respuesta del servidor para mostrar un mensaje enviado hace que la app se sienta lenta aunque no lo sea. Lo optimista es mostrarlo ya y revertir si falla.

Escribir eso a mano significa mantener una lista temporal, reconciliarla con la respuesta y deshacer en el catch. useOptimistic lo hace por vos.

React real

useOptimistic

El mensaje aparece al instante y se revierte solo si la acción falla. Ojo con el segundo envío: ese falla a propósito.

Código

1const { useOptimistic, useActionState } = React;2 3let enviados = 0;4 5async function enviar(_previo, formData) {6  const texto = formData.get("texto");7  await new Promise(r => setTimeout(r, 900));8  enviados++;9  if (enviados % 2 === 0) return { error: "La red falló." };10  return { mensajes: texto };11}12 13function App() {14  const [estado, accion] = useActionState(enviar, { mensajes: null });15  const [optimista, agregarOptimista] = useOptimistic(16    estado.mensajes,17    (_actual, nuevo) => nuevo + " (enviando…)",18  );19 20  return (21    <form action={(fd) => { agregarOptimista(fd.get("texto")); return accion(fd); }}>22      <input name="texto" defaultValue="hola" />23      <button>enviar</button>24      <p>{optimista ?? "sin mensajes"}</p>25      {estado.error && <p style={{ color: "#ff7b93" }}>{estado.error}</p>}26    </form>27  );28}

Corriendo de verdad

bajando React 19.2.0 de esm.sh…

Registro0 commits

Todo lo que imprimas con console.log aparece acá. Los commits los reporta el Profiler de React de verdad.

Enviá dos veces: el segundo envío falla a propósito. Mirá cómo el mensaje aparece al instante y se revierte solo cuando la acción devuelve el error. Vos no escribís ni una línea de reversión — React descarta el estado optimista cuando la acción termina.

1const [optimista, agregarOptimista] = useOptimistic(2  estadoReal,                                  // la verdad3  (actual, valorNuevo) => [...actual, valorNuevo],   // cómo se ve mientras tanto4);5 6// Durante la acción, la interfaz lee "optimista".7// Cuando la acción termina, React vuelve a "estadoReal" — salió bien o falló.

Server Actions: la misma idea, cruzando el límite

Con la directiva "use server", la función se ejecuta en el servidor aunque la dispare un click en el navegador. Al cliente no viaja el código: viaja una referencia.

1"use server";2 3export async function crearTarea(estadoPrevio, formData) {4  const titulo = formData.get("titulo");5  await db.tareas.insert({ titulo });      // acceso directo a la base6  revalidatePath("/tareas");7  return { ok: true };8}

Es la única excepción a la regla de serialización que vimos en la lección de Server Components: una función que cruza el límite. Y cruza porque no es la función lo que viaja, sino su identificador.

Antes de seguir · predecí

Ponés useFormStatus en el mismo componente donde está el <form> y pending nunca se pone en true. ¿Por qué?

Lo que te llevás

Una Action es una función asincrónica que React conecta al ciclo de vida del formulario. useActionState es un reducer asincrónico que devuelve el estado en vez de setearlo. useFormStatus lee desde un hijo, por la misma pila que useContext. Y useOptimistic revierte solo, sin que escribas la reversión.

Antes de marcarla, comprobá

  • ¿Podés explicar explicar qué maneja React por vos cuando pasás una función a la prop action?
  • ¿Podés explicar usar useFormStatus desde un hijo y decir por qué no funciona en el mismo componente?
  • ¿Podés explicar describir cómo useOptimistic revierte un cambio sin que vos escribas nada?