wandres.dev
ACTIONS Y FORMS · mutaciones progresivas

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.

⏱ 17 min

El progressive enhancement no es una técnica que se añade a un formulario de Solid: es una propiedad que cae por su propio peso de cómo está construida una action. Como la action tiene una .url estable, un <form> que la usa es un formulario HTML de verdad apuntando a un endpoint real. Eso significa que funciona sin una línea de JavaScript en el cliente —el navegador sabe hacer POST desde el paleolítico de la web— y que, cuando el JavaScript carga, el router lo intercepta para mejorarlo, no para hacerlo posible. La red es el runtime de reserva. Esta lección explica esa doble vida del formulario y cómo se leen sus datos.

🎯 Al terminar esta lección sabrás
  • Entender que un <form action={…} method="post"> funciona sin JavaScript porque la action es un endpoint real.
  • Distinguir el envío nativo (POST + navegación) del envío mejorado (interceptado + revalidado sin recarga).
  • Leer los datos con la interfaz común de FormData y URLSearchParams: get, getAll, entries.
  • Saber cuándo llega FormData (enctype multipart/form-data) y cuándo URLSearchParams, y prefijar con .with().

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.

flowchart TD
F[form action con method post] --> SINJS[sin javascript]
F --> CONJS[con javascript]
SINJS --> N1[el navegador hace un post nativo a la url]
N1 --> N2[el servidor corre la action y responde]
N2 --> N3[el navegador sigue la respuesta con una navegacion]
CONJS --> S1[el router intercepta el envio]
S1 --> S2[corre la action sin recarga completa]
S2 --> S3[revalida queries y actualiza en sitio]
style N3 fill:#f9e2af,color:#11111b
style S3 fill:#a6e3a1,color:#11111b
⚠️
method='post' no es opcional: es lo que hace legítimo al formulario

Si olvidas method="post", el navegador trata el envío como una navegación GET y la action nunca corre —ni en la vía nativa ni en la interceptada—. El POST es lo que convierte al <form> en una mutación de verdad. Esta es también la razón por la que las mutaciones nunca deben ser GET: una petición GET debe ser segura e idempotente, y un rastreador o un prefetch podría dispararla sin querer.

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.

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

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.

📝
La interfaz común es lo que hace intercambiables ambos encodings

Que FormData y URLSearchParams compartan get, getAll y entries no es casualidad: es lo que permite que tu código de servidor no dependa del enctype del formulario. Escribes el parseo una vez y sirve tanto si el usuario envió un formulario urlencoded como uno multipart con archivos. La única diferencia práctica aparece cuando lees un campo de archivo, que solo existe en FormData; para todo lo demás, los dos objetos son intercambiables desde el punto de vista de tu lógica.

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.

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

Funciona sin JS

El form apunta a la url real de la action; sin scripts el navegador hace un POST nativo y sigue la respuesta.

Mejora con JS

El router intercepta el mismo envio, corre la mutacion sin recarga y revalida las queries en su sitio.

📦

get, getAll, entries

FormData y URLSearchParams comparten interfaz de lectura; todo llega como texto o File y se valida en el servidor.

El progressive enhancement no se programa: se hereda de que la action es un endpoint

La lección profunda es invertir el orden de causalidad con el que solemos pensar los formularios. El instinto de quien viene de una SPA clásica es: «escribo un manejador de onSubmit, hago un fetch, actualizo el estado, y si me sobra tiempo añado un fallback para cuando no haya JavaScript». En Solid la dirección es la contraria. Primero existe un formulario HTML honesto —un <form> con method="post" que apunta a una url— y ese formulario ya es funcional con el motor nativo del navegador, sin que tú escribas nada. El JavaScript llega después y su papel no es habilitar el envío, sino interceptarlo para mejorarlo: quitar la recarga, revalidar en sitio, mostrar estado de progreso. Por eso el progressive enhancement aquí no es una tarea que se hace, sino una propiedad que se hereda: cae directamente de que una action tiene identidad de endpoint. Y esto reordena tus prioridades de diseño. La pregunta deja de ser «cómo hago que este formulario funcione» —ya funciona— y pasa a ser «qué le añado cuando hay JavaScript». La consecuencia práctica es una robustez que no cuesta esfuerzo: tu aplicación sigue enviando datos aunque un bundle falle en cargar, aunque el usuario esté en un túnel, aunque la hidratación no haya terminado todavía. La red es el runtime que nunca se cae, y construir sobre ella en lugar de a pesar de ella es lo que separa un formulario frágil de uno que resiste el mundo real.

⚔️ Construye un formulario que sobreviva sin JavaScript
  1. Monta un <form action={crearPerfil} method="post"> y envíalo con el JavaScript desactivado en el navegador; verifica que la mutación ocurre igualmente.
  2. Reactiva el JavaScript y observa la diferencia: el mismo envío ahora no recarga la página y la lista se actualiza en su sitio.
  3. Añade dos checkbox con el mismo name y léelos con getAll; comprueba que un checkbox no marcado no aparece en los datos.
  4. Cambia el enctype a multipart/form-data, añade un <input type="file"> y confirma que get te devuelve un File; razona por qué el enctype por defecto no serviría.
  5. Usa editarNota.with(id) en una fila y explica por qué el id sobrevive a la vía nativa aunque no haya scripts para pasarlo.