wandres.dev
FORMULARIOS COMO ESTADO · el caso difícil

Estado de envío: pending, error del servidor y mutaciones

El envio es la dimension del formulario que cruza la frontera con el mundo, y por eso la mas dificil: es una maquina asincrona con estados de pendiente, exito y fallo, y trae consigo una clase de error que ninguna regla local predice, el error del servidor. Esta leccion modela ese ciclo con rigor —ocioso, enviando, exito, fallo, sin estados a la vez— y explica como fundir los errores remotos con los de validacion sin confundirlos, mapeando los 4xx por campo con algo como `setError`. Conecta el envio con las mutaciones de una capa como TanStack Query, donde `isPending` gobierna el boton deshabilitado, `onError` inyecta los errores del servidor y `onSuccess` reinicia o redirige. Y llega hasta las actualizaciones optimistas: aplicar el cambio en la cache antes de la confirmacion y revertirlo si falla, coordinando dos maquinas de estado —la del formulario y la de la cache— que deben deshacerse juntas.

⏱ 19 min

Las cuatro dimensiones anteriores del formulario viven dentro del navegador, bajo tu control absoluto: los valores, la validez, el touched y el dirty son verdades locales que tu calculas y tu gobiernas. El envio es distinto, porque es la unica que cruza la frontera con el mundo. En cuanto el usuario pulsa enviar, el formulario deja de ser dueño de su destino y queda a merced de una red que tarda, de un servidor que puede aceptar o rechazar, y de errores que ninguna regla local podia prever: el correo ya esta registrado, el pago fue denegado, la sesion caduco. Modelar bien el envio es aceptar que es una maquina de estado asincrona, integrarla con la capa que habla con el servidor y, en el limite, coordinarla con una cache que se adelanta a la respuesta. Esta leccion cierra el nivel llevando el formulario hasta esa frontera y de vuelta.

🎯 Al terminar esta lección sabrás
  • Modelar el envio como una maquina asincrona —ocioso, enviando, exito, fallo— que nunca esta en dos estados a la vez.
  • Fundir los errores del servidor con los de validacion sin confundirlos, mapeando los fallos por campo con setError.
  • Integrar el envio con una mutacion de TanStack Query usando isPending, onError y onSuccess.
  • Entender las actualizaciones optimistas como la coordinacion de dos maquinas de estado que deben revertirse juntas.

El envío es una máquina asíncrona

El error mas comun al modelar el envio es reducirlo a un booleano enviando. Un booleano no distingue entre “aun no he enviado” y “envie y fallo”, y desde el no sabes si mostrar el formulario limpio, un spinner, un mensaje de exito o un error. El envio tiene cuatro estados —ocioso, enviando, exito, fallo— y la disciplina de statecharts que estudiaste antes se aplica intacta: solo se puede estar en uno a la vez, de enviando solo se sale a exito o a fallo, y desde fallo se puede reintentar volviendo a enviando. Un solo valor de estado, no cuatro banderas que pueden contradecirse.

stateDiagram-v2
[*] --> ocioso
ocioso --> enviando : enviar
enviando --> exito : respuesta ok
enviando --> fallo : respuesta error
fallo --> enviando : reintentar
exito --> ocioso : reiniciar
exito --> [*]

De este modelo salen dos obligaciones practicas que casi todo formulario descuida. La primera es impedir el doble envio: mientras el estado sea enviando, el boton debe estar deshabilitado, porque un usuario impaciente que pulsa tres veces puede crear tres registros. La segunda es que cada estado dicta que se muestra —el boton activo en ocioso, un spinner en enviando, un aviso en exito, el error mas el boton de reintento en fallo— sin combinaciones ambiguas. La maquina no es teoria: es lo que evita el pedido duplicado y el spinner que no se va.

El estado de exito merece una decision explicita que casi nadie toma: ¿que ocurre despues? Un formulario de creacion suele reiniciarse para admitir otro registro; uno de edicion suele quedarse mostrando los valores guardados; uno de un asistente suele redirigir al siguiente paso. Dejar esa transicion sin decidir produce el clasico formulario que, tras guardar, no se sabe si sigue editable, si repetira el envio al volver a pulsar o si el usuario deberia irse. Modelar el exito no es pintar un tic verde: es elegir a que estado se va desde ahi.

Errores del servidor: fundir sin confundir

Aqui aparece la dificultad genuina del envio. Hasta ahora todos los errores eran locales y derivados: nacian de comparar los valores con el esquema. El error del servidor es de otra naturaleza —es fuente, llega tarde y no lo predice ninguna regla del cliente—, y sin embargo el usuario espera verlo en el mismo sitio que los demas: bajo el campo culpable. La tarea es fundir dos origenes de error en una sola presentacion sin mezclar su naturaleza.

La tecnica es mapear la respuesta de fallo del servidor al mismo mapa de errores por campo que ya usa la validacion. Si el backend responde que el correo ya existe, se inyecta ese mensaje bajo el campo email con una funcion como setError, de modo que la vista no necesita saber si el error vino de Zod o de la red: lee el mismo mapa. Los errores que no pertenecen a ningun campo —la sesion caducada, un fallo de servidor— van a un espacio de error de formulario, no a un campo.

async function onSubmit(datos, { setError }) {
  const res = await fetch("/api/registro", { method: "POST", body: JSON.stringify(datos) });
  if (res.ok) return; // la maquina pasa a exito

  const problema = await res.json();
  if (problema.campo) {
    // error 4xx atribuible a un campo: se funde con los de validacion
    setError(problema.campo, { type: "server", message: problema.mensaje });
  } else {
    // error sin campo: sesion caducada, 5xx... va al error de formulario
    setError("root.server", { type: "server", message: "No se pudo completar el envio" });
  }
}
⚠️
No borres los errores del servidor con la siguiente validación local

El bug clasico de esta fusion es que el error del servidor desaparece en cuanto el usuario teclea, porque una validacion local se ejecuta, no encuentra fallo y limpia el mapa entero. Pero el error remoto no es una funcion de los valores actuales: que el correo “ya exista” no se puede refutar tecleando. Trata los errores de servidor como fuente que solo se limpia cuando el propio campo cambia de verdad o cuando se reintenta el envio, nunca como un derivado que la siguiente pasada de validacion pueda pisar. Fundir en la presentacion no significa confundir en la logica: comparten la caja donde se muestran, no la regla que los borra.

Antes de conectar el envio con el servidor conviene fijar una distincion que evita mucho dolor: no todos los errores del servidor son iguales. Unos son transitorios —un tiempo de espera agotado, un 503 momentaneo— y piden reintento, dejando el formulario intacto para volver a pulsar. Otros son de dominio —el correo ya existe, el saldo no llega— y no se arreglan reintentando, sino corrigiendo un campo. La maquina de envio debe distinguirlos, porque el primero lleva de fallo a enviando con los mismos datos, y el segundo lleva de fallo a ocioso esperando que el usuario cambie algo. Tratar ambos igual produce el formulario que reintenta en bucle un error que jamas cambiara solo.

Esa distincion tambien decide que se limpia y cuando. Un error transitorio no ensucia ningun campo, asi que vive en el error de formulario y desaparece al reintentar. Un error de dominio vive bajo su campo y desaparece cuando ese campo cambia, como vimos. Mapear la respuesta del servidor no es solo elegir donde poner el mensaje, sino a que transicion de la maquina pertenece.

Integración con mutaciones y actualizaciones optimistas

En una aplicacion real, el onSubmit rara vez llama a fetch a pelo: llama a una mutacion de una capa como TanStack Query, que ya modela la maquina asincrona por ti. La mutacion expone isPending —que conectas directamente al boton deshabilitado y al spinner—, un callback onError —donde inyectas los errores del servidor con setError— y un onSuccess —donde reinicias el formulario, invalidas las consultas afectadas o rediriges—. El estado de envio del formulario y el estado de la mutacion son la misma maquina vista desde dos lados, y conviene no duplicarla: deja que la mutacion sea la fuente de verdad del ciclo asincrono.

const mutacion = useMutation({
  mutationFn: (datos) => api.registrar(datos),
  onError: (error) => mapearErroresDelServidor(error, setError),
  onSuccess: () => { form.reset(); queryClient.invalidateQueries({ queryKey: ["perfil"] }); },
});
// isPending gobierna el boton; el resto lo dicta la maquina de la mutacion
<button disabled={mutacion.isPending}>Guardar</button>
💡
Deshabilitar el botón es defensa, no garantía

Deshabilitar el boton mientras isPending es verdadero evita el doble clic ingenuo, pero es una defensa de cliente, y el cliente nunca es de fiar: una red lenta, un reintento automatico o una pestaña duplicada pueden colar el mismo envio dos veces pese al boton gris. La garantia real vive en el servidor, con una clave de idempotencia —un identificador que el cliente genera una vez por intento y el servidor usa para reconocer y descartar el duplicado—. El estado de envio del formulario reduce la probabilidad del registro doble; la idempotencia del servidor la elimina. Un formulario serio hace las dos cosas y no confia la integridad de los datos a un atributo disabled.

El escalon final es la actualizacion optimista: en vez de esperar la respuesta para reflejar el cambio, aplicas el resultado esperado a la cache al instante y, si el servidor falla, lo reviertes. Aqui coexisten dos maquinas de estado que hay que coordinar: la del envio del formulario y la de la cache, que ha avanzado por adelantado. Si el envio termina en fallo, no basta con mostrar el error: hay que deshacer tambien el cambio optimista de la cache para no dejar en pantalla un dato que el servidor rechazo. Las dos maquinas avanzan juntas y, sobre todo, deben retroceder juntas.

El patron concreto tiene tres tiempos, y TanStack Query los expone como tres callbacks. En onMutate aplicas el cambio a la cache y —clave— guardas el valor previo como punto de retorno. En onError restauras ese valor previo, revirtiendo en bloque. Y en onSettled invalidas la consulta para que la verdad del servidor tenga la ultima palabra, gane o pierda:

const mutacion = useMutation({
  mutationFn: (datos) => api.actualizarPerfil(datos),
  onMutate: async (datos) => {
    await queryClient.cancelQueries({ queryKey: ["perfil"] });
    const previo = queryClient.getQueryData(["perfil"]);
    queryClient.setQueryData(["perfil"], datos); // la cache se adelanta
    return { previo };                            // punto de retorno
  },
  onError: (_err, _datos, ctx) => {
    queryClient.setQueryData(["perfil"], ctx?.previo); // revierte en bloque
  },
  onSettled: () => queryClient.invalidateQueries({ queryKey: ["perfil"] }),
});
flowchart TD
E[enviar] --> OM[onMutate aplica en la cache]
OM --> P[peticion al servidor]
P --> OK[onSuccess confirma]
P --> ERR[onError revierte la cache]
ERR --> UI[formulario en fallo y cache restaurada]
OK --> ST[onSettled revalida contra el servidor]
style E fill:#89b4fa,color:#11111b
style OM fill:#f9e2af,color:#11111b
style ERR fill:#f38ba8,color:#11111b
style OK fill:#a6e3a1,color:#11111b
style UI fill:#cba6f7,color:#11111b

La actualizacion optimista compra una sensacion de inmediatez que ninguna red rapida iguala, pero cobra su precio en disciplina: cada cambio adelantado es una promesa que el servidor puede incumplir, y sin el punto de retorno guardado en onMutate esa promesa rota deja la interfaz mintiendo. Por eso el optimismo bien hecho no es fe ciega en el exito, sino un plan de reversion preparado de antemano para el fallo.

Cierra el circulo una coreografia que muchos formularios olvidan: tras un exito real, el estado del formulario y el de la cache deben reconciliarse con la verdad del servidor. Reiniciar el formulario a los nuevos valores guardados y revalidar las consultas afectadas evita el fantasma de un formulario que sigue marcando cambios sin guardar sobre datos ya persistidos, o de una lista que no refleja lo que se acaba de crear. El envio no termina cuando el servidor responde que si: termina cuando las dos maquinas —formulario y cache— reconocen esa respuesta como su nuevo punto de partida.

El envío no termina en el cliente: coordina dos máquinas que deben caer juntas

La leccion que corona el nivel es que el estado de envio no es una bandera al final del formulario, sino el punto donde tu interfaz negocia con una realidad que no controlas. Todo lo anterior —valores, validez, touched, dirty— era soberano; el envio es diplomacia con un servidor que puede decir que no por razones que tu cliente jamas podria adivinar. Modelarlo bien exige tres disciplinas que se apoyan entre si. Primero, tratar el ciclo como una maquina de estado y no como booleanos sueltos, para que el boton se deshabilite mientras se envia y para que nunca convivan el spinner y el mensaje de exito. Segundo, tratar el error del servidor como una fuente de naturaleza distinta a la validacion: se funde con ella en la caja donde se muestra, pero no comparte la regla que la borra, porque un correo que ya existe no se corrige tecleando. Y tercero, cuando persigas la sensacion de inmediatez con una actualizacion optimista, recordar que has puesto en marcha una segunda maquina de estado en la cache que se adelanto a la verdad, y que si el servidor rechaza el cambio esas dos maquinas deben revertirse en bloque, no a medias. El formulario que domina estas tres cosas es el que se siente solido bajo una red lenta y un servidor caprichoso: no promete lo que no puede cumplir, no pierde el error que costo un viaje de ida y vuelta, y no deja en pantalla un exito que el mundo real desmintio. Ahi termina el viaje del formulario como estado: no en el onChange, sino en la frontera con lo que no controlas y en el arte de retroceder con elegancia cuando el otro lado dice que no.

⚔️ Lleva un formulario hasta la frontera y de vuelta
  1. Sustituye el booleano enviando de un formulario tuyo por una maquina de cuatro estados y comprueba que el boton se deshabilita solo mientras esta enviando.
  2. Provoca un error 4xx atribuible a un campo desde el servidor y mapealo con setError para que aparezca bajo el campo correcto, junto a los errores de validacion.
  3. Reproduce el bug de que el error del servidor desaparece al teclear, y arreglalo tratandolo como fuente que solo se limpia al cambiar el campo o al reintentar.
  4. Reescribe el onSubmit para que llame a una mutacion de TanStack Query y conecta isPending, onError y onSuccess a la vista, al mapeo de errores y al reinicio.
  5. Implementa una actualizacion optimista sobre la cache y, deliberadamente, haz fallar el servidor para verificar que el cambio se revierte por completo.
  6. Escribe en una frase por que el envio es la unica dimension del formulario que negocia con algo que no controlas, y que disciplina extra exige por ello.