Saltar al contenido

Formularios con progressive enhancement y FormData

Como una action tiene una url estable, un `<form action={…} method="post">` es un formulario HTML legítimo: sin JavaScript el navegador hace un POST nativo y sigue la respuesta del servidor; con JavaScript el router intercepta el envío, corre la mutación sin recarga y revalida. El argumento que recibe la action depende del enctype —`FormData` para `multipart/form-data`, `URLSearchParams` en otro caso— pero ambos comparten la interfaz de lectura get, getAll y entries. Prefijar con `.with()` fija argumentos antes de los del formulario.

La doble vida del formulario

Un mismo <form action={crearNota} method="post"> se comporta de dos maneras según haya o no JavaScript ejecutándose, y el punto clave es que la primera no depende de la segunda. Sin JavaScript —la primera carga, una conexión lenta que aún no hidrató, un navegador con scripts desactivados—, el envío es una operación nativa del navegador: hace un POST a la .url de la action, el servidor corre el cuerpo y responde, y el navegador sigue esa respuesta con una navegación normal. La aplicación funciona con el motor de formularios que la web tiene incorporado desde siempre.

Cuando el JavaScript está presente, el router intercepta ese mismo envío antes de que el navegador lo mande a ciegas: ejecuta la action, evita la recarga completa de la página, y al terminar revalida las queries afectadas para que la UI se actualice en su sitio. El resultado visible es una experiencia de aplicación —sin parpadeo, sin perder el scroll—, pero por debajo es exactamente la misma mutación que se habría ejecutado sin scripts. Escribes el formulario una vez y obtienes las dos versiones.

El detalle que hace valioso este diseño es el intervalo entre que el HTML llega y que el JavaScript termina de hidratar. En ese hueco —que en una conexión lenta o un dispositivo modesto puede durar segundos— una SPA clásica tiene formularios muertos: el usuario pulsa Enviar y no ocurre nada, porque el manejador todavía no existe. Con una action, ese mismo formulario ya está vivo, porque su motor es el navegador y no tu bundle. El progressive enhancement no es, pues, solo una red para el raro visitante sin JavaScript: es lo que hace que tu aplicación responda desde el primer milisegundo en que hay HTML en pantalla.

Leer los datos: FormData y su interfaz

El argumento que recibe la action al enviarse de forma nativa depende del enctype del formulario. Con el enctype por defecto (application/x-www-form-urlencoded) llega un URLSearchParams; con enctype="multipart/form-data" —obligatorio si hay campos de archivo— llega un FormData. La buena noticia es que ambos objetos comparten la misma interfaz de lectura, así que tu código de servidor apenas cambia: get para un valor, getAll para varios campos con el mismo name, entries para recorrerlos todos.

TSX
<form action={crearPerfil} method="post" enctype="multipart/form-data">  <input name="nombre" />  <input name="intereses" type="checkbox" value="solid" />  <input name="intereses" type="checkbox" value="astro" />  <input name="avatar" type="file" />  <button type="submit">Guardar</button></form>
TypeScript
const crearPerfil = action(async (form: FormData) => {  "use server";  const nombre = String(form.get("nombre") ?? "").trim();  const intereses = form.getAll("intereses").map(String); // varios name iguales  const avatar = form.get("avatar");                       // File o null  const edad = Number(form.get("edad"));                   // todo llega como texto  if (!nombre) return { error: "El nombre es obligatorio" };  await db.perfiles.crear({ nombre, intereses });}, "crearPerfil");

Dos cautelas de coerción que hay que grabar. Primero: todo valor de un campo de texto llega como cadena; un número es la cadena "42" hasta que tú lo pasas por Number(...), y una fecha es texto hasta que la parseas. Segundo: un checkbox no marcado no aparece en absoluto en los datos —get devuelve null, no false—; para saber si se marcó, compruebas su presencia o su valor, típicamente form.get("terminos") === "on". La validación seria vive siempre en el servidor, dentro del "use server", porque cualquier validación de cliente puede saltarse enviando el formulario a mano.

Del FormData crudo a un objeto validado

En una action real no lees campo a campo esparcido por toda la lógica: conviertes el FormData en un objeto de dominio validado de una sola pasada, y decides ahí mismo si continúas o retornas errores. Este patrón —parsear y validar en la frontera— mantiene el resto del cuerpo trabajando con datos limpios y tipados, y concentra en un único punto la desconfianza hacia la entrada del usuario.

TypeScript
type DatosNota = { texto: string; etiquetas: string[] };function parsearNota(form: FormData): { datos?: DatosNota; error?: string } {  const texto = String(form.get("texto") ?? "").trim();  const etiquetas = form.getAll("etiquetas").map(String).filter(Boolean);  if (texto.length < 3) return { error: "El texto es demasiado corto" };  if (etiquetas.length > 5) return { error: "Como maximo cinco etiquetas" };  return { datos: { texto, etiquetas } };}const crearNota = action(async (form: FormData) => {  "use server";  const { datos, error } = parsearNota(form);  if (error) return { error };            // desenlace esperado, cae en result  await db.notas.crear(datos!);}, "crearNota");

Con una librería de esquemas —Zod, Valibot— este parseo se vuelve declarativo: describes la forma esperada y la coerción y la validación salen del esquema. Pero el principio no cambia, y es el que hay que interiorizar: el FormData es entrada no confiable que cruzó la red, y la primera línea útil de tu servidor es convertirla en datos de los que puedas fiarte.

Prefijar argumentos con .with()

El formulario aporta sus datos como último argumento de la action. Cuando necesitas fijar algo antes —el id del recurso que se edita, el usuario dueño—, .with() devuelve otra action con esos argumentos ya rellenados, y funciona igual en la vía nativa que en la interceptada, porque .with() codifica los valores prefijados en la propia url del formulario.

TSX
const editarNota = action(async (id: string, form: FormData) => {  "use server";  await db.notas.actualizar(id, { texto: String(form.get("texto") ?? "") });}, "editarNota");// El id va prefijado; el navegador anade el FormData detras al enviar<form action={editarNota.with(nota.id)} method="post">  <input name="texto" value={nota.texto} />  <button type="submit">Guardar</button></form>