Saltar al contenido

Formularios HTML progresivos con Actions

Actions que funcionan sin una línea de JavaScript: enlazar un formulario con action={actions.miAccion}, leer el resultado en la página con Astro.getActionResult, y persistir ese estado a través de un redirect con getActionContext y la sesión, para lograr el patrón post-redirect-get que sobrevive a un refresco.

El formulario que no necesita JavaScript

El puente entre un formulario y una action se tiende en el atributo action. En lugar de una cadena con una URL, le pasas la propia action, y Astro genera por debajo la ruta y los campos ocultos que hacen falta para enrutar el envío. El formulario ha de usar method="POST", y la action, aceptar formularios con accept: 'form', como vimos al hablar del input.

ASTRO
---import { actions } from 'astro:actions';---<form method="POST" action={actions.suscribir}>  <input type="email" name="email" required />  <label><input type="checkbox" name="acepta" /> Acepto</label>  <button type="submit">Suscribirme</button></form>

Sin una sola línea de JavaScript, este formulario funciona. Al pulsar el botón, el navegador hace su envío de siempre, Astro recibe el FormData, lo valida contra el input de suscribir, ejecuta el handler y vuelve a renderizar la página. Todo el ciclo ocurre en el servidor, con la mecánica que la web tiene desde su origen. El día que añadas una isla que intercepte el envío para evitar la recarga, el formulario mejorará; pero ese día es opcional, porque lo esencial ya funcionaba sin él.

Astro.getActionResult: leer el resultado en la página

Tras el envío sin JavaScript, la página se vuelve a renderizar, y necesitas una forma de saber qué pasó para mostrarlo. Esa forma es Astro.getActionResult, que en el frontmatter de la página recibe la action y devuelve su resultado { data, error } para la petición actual, o undefined si esta action no fue la que se envió.

ASTRO
---import { actions, isInputError } from 'astro:actions';const resultado = Astro.getActionResult(actions.suscribir);const inputError = isInputError(resultado?.error) ? resultado.error : undefined;---<form method="POST" action={actions.suscribir}>  <input type="email" name="email" required />  {inputError?.fields.email && <p class="error">{inputError.fields.email}</p>}  <button type="submit">Suscribirme</button></form>{resultado?.data && <p class="ok">Listo, revisa tu correo</p>}

El resultado tiene la misma forma que en el cliente —el { data, error } de siempre— y las mismas herramientas para leerlo: isInputError para sacar los fields y pintar el fallo junto a cada campo, data para confirmar el éxito. La diferencia es dónde ocurre: todo en el servidor, dentro del frontmatter, sin un <script> a la vista. La validación que declaraste en el input se convierte, sin esfuerzo extra, en mensajes de error renderizados en el HTML que el navegador recibe ya pintado.

Un detalle de ergonomía cierra este patrón: cuando la validación falla, el usuario no debería perder lo que ya escribió. Como el resultado del envío está disponible en el servidor, puedes rellenar el atributo value de cada campo con lo que vino en la petición, de modo que el formulario se repinta con los datos previos y solo señala lo que hay que corregir. Sin una línea de JavaScript, un formulario que conserva lo tecleado y marca el error exacto ofrece una experiencia que durante años se creyó privativa de las aplicaciones cargadas de scripts.

Persistir el estado tras un redirect

El patrón anterior tiene un flanco conocido de los formularios que responden a un POST: si el usuario refresca la página, el navegador reenvía el formulario, repitiendo la operación. La cura clásica es el patrón post-redirect-get: tras procesar el envío, el servidor responde con un redirect a una página que se sirve con GET, de modo que el refresco recarga esa página inocua en vez de reenviar. Pero al redirigir se pierde el resultado de la action, y hay que persistirlo para poder mostrarlo después.

La pieza que lo permite es getActionContext, invocada desde el middleware. Te da información sobre la petición de action en curso —incluido si vino de un formulario o de una llamada del cliente— y las herramientas para capturar su resultado, serializarlo y guardarlo donde sobreviva al redirect, típicamente la sesión.

TypeScript
// src/middleware.tsimport { defineMiddleware } from 'astro:middleware';import { getActionContext } from 'astro:actions';export const onRequest = defineMiddleware(async (context, next) => {  const { action, setActionResult, serializeActionResult } =    getActionContext(context);  if (action?.calledFrom === 'form') {    const resultado = await action.handler();    // guarda el resultado en la sesion para que sobreviva al redirect    await context.session?.set('ultimoResultado', serializeActionResult(resultado));    setActionResult(action.name, serializeActionResult(resultado));  }  return next();});

El flujo completo se lee así: el formulario envía con POST; el middleware detecta que la action vino de un formulario —action.calledFrom === 'form'—, ejecuta su handler, serializa el resultado con serializeActionResult y lo deja en la sesión; luego la página redirige con un GET, y en esa nueva petición Astro.getActionResult recupera el estado persistido para renderizarlo. El serializeActionResult es imprescindible porque el resultado puede contener un Date, un Map o un ActionError, cosas que un JSON pelado no sabe guardar; su inverso, deserializeActionResult, lo devuelve a la vida al leerlo.

Con este circuito, un formulario sin JavaScript alcanza la misma robustez que uno con toda la maquinaria del cliente: valida, muestra errores por campo, confirma el éxito y sobrevive a un refresco sin reenviarse. Es la mejora progresiva en su forma más plena: la base funciona para todos, y el JavaScript, si aparece, solo recorta las recargas.

Rastro en la URL y cómo limpiarlo

Cuando un formulario se envía a una action sin JavaScript, Astro añade a la URL un par de parámetros internos para saber qué action se llamó y con qué datos. El objeto ACTION_QUERY_PARAMS expone los nombres de esos parámetros, útil si quieres borrarlos al redirigir para que el usuario acabe en una dirección limpia, sin el rastro de la mecánica interna.

TypeScript
import { ACTION_QUERY_PARAMS } from 'astro:actions';const url = new URL(context.request.url);url.searchParams.delete(ACTION_QUERY_PARAMS.actionName);url.searchParams.delete(ACTION_QUERY_PARAMS.actionPayload);

Este barrido es el toque final del patrón post-redirect-get: no solo evitas el reenvío al refrescar, sino que dejas la barra de direcciones tan aseada como si el envío nunca hubiera dejado huella. El estado vive en la sesión, no en la URL, que es justo donde debe vivir.