Datos y formularios

Por: Artiko
htmxhtmx4formularioshx-valsvalidacionuploads

Datos y formularios

Los formularios son el terreno natural de htmx: es donde el modelo hipermedia se nota más simple que el de una SPA. Este capítulo cubre cómo se arma la carga de una petición y cómo se validan los datos.

hx-vals: valores adicionales

Añade pares clave/valor a la petición sin necesidad de inputs ocultos:

<!-- HCON -->
<button hx-post="/api" hx-vals="prioridad:'alta' origen:'panel'">Enviar</button>

<!-- JSON -->
<button hx-post="/api" hx-vals='{"prioridad": "alta", "tags": ["urgente"]}'>Enviar</button>

Valores dinámicos con js:

Cuando el valor hay que calcularlo en el momento:

<button hx-post="/eventos"
        hx-vals='js:{ancho: window.innerWidth, hora: new Date().toISOString()}'>
  Registrar
</button>

El prefijo js: hace que htmx evalúe la expresión en cada petición. En htmx 4 esto reemplaza a hx-vars, que fue eliminado.

Si tu sitio tiene una CSP estricta sin unsafe-eval, js: no funcionará. La alternativa es el evento htmx:config:request, que se ve en el capítulo 10.

hx-include: incluir valores de otros elementos

Por defecto, un elemento sólo envía sus propios valores (o los de su formulario). hx-include suma los de cualquier otro elemento del documento:

<div id="filtros">
  <select name="categoria">
    <option value="todas">Todas</option>
    <option value="libros">Libros</option>
  </select>
  <input type="checkbox" name="stock" value="1"> Sólo con stock
</div>

<button hx-get="/productos" hx-include="#filtros" hx-target="#resultados">
  Buscar
</button>

Acepta los mismos selectores extendidos que hx-target:

<button hx-delete="/items/3" hx-include="closest form">Eliminar</button>
<button hx-post="/guardar" hx-include="next input">Guardar</button>
<button hx-post="/enviar" hx-include="#a, #b, .campos-extra">Enviar</button>

Cuando el selector apunta a un contenedor, htmx incluye todos los campos de formulario que haya dentro.

Recuerda el cambio de htmx 4: hx-delete ya no incluye automáticamente el formulario que lo contiene. Si lo necesitas, hx-include="closest form".

hx-headers: headers personalizados

<button hx-get="/protegido" hx-headers='{"X-API-Key": "secreto"}'>Consultar</button>

El uso más común es el token CSRF, declarado una vez en el layout con herencia:

<body hx-headers:inherited='{"X-CSRF-Token": "{{ token }}"}'>

Y si un botón concreto necesita añadir otro header sin perder el heredado:

<button hx-post="/api" hx-headers:append='{"X-Request-ID": "abc"}'>Enviar</button>

hx-encoding y subida de archivos

Los formularios con archivos necesitan multipart/form-data:

<form hx-post="/subir"
      hx-encoding="multipart/form-data"
      hx-target="#resultado">
  <input type="file" name="archivo" required>
  <button type="submit">Subir</button>
</form>
<div id="resultado"></div>

En el servidor:

if (pathname === "/subir" && req.method === "POST") {
  const datos = await req.formData();
  const archivo = datos.get("archivo");
  await Bun.write(`./uploads/${archivo.name}`, archivo);
  return html(`<p>Subido: ${archivo.name} (${archivo.size} bytes)</p>`);
}

Indicador durante la subida

htmx 4 usa fetch(), que no expone eventos de progreso de subida como lo hacía XMLHttpRequest. Para una subida grande, lo práctico es un indicador indeterminado más un timeout generoso:

<form hx-post="/subir"
      hx-encoding="multipart/form-data"
      hx-config="timeout:300000"
      hx-indicator="#subiendo"
      hx-target="#resultado">
  <input type="file" name="archivo">
  <button type="submit">Subir</button>
  <progress id="subiendo" class="htmx-indicator"></progress>
</form>

Si necesitas un porcentaje real, el camino en htmx 4 es la extensión hx-multipart o un stream de progreso del servidor con hx-sse, que se ven en el capítulo 14.

Validación

htmx 4 se apoya en la validación nativa de HTML5. Un formulario con campos inválidos no se envía, sin que tengas que hacer nada:

<form hx-post="/registro" hx-target="#resultado">
  <input type="email" name="email" required>
  <input type="password" name="clave" minlength="8" required>
  <button type="submit">Registrarse</button>
</form>

En htmx 4 se eliminaron los eventos htmx:validation:*: la validación es la del navegador, con sus APIs estándar (setCustomValidity, :invalid, checkValidity()).

hx-validate fuera de un formulario

Un input suelto que dispara una petición no se valida por defecto. Con hx-validate="true", sí:

<input type="email"
       name="email"
       required
       hx-post="/verificar-email"
       hx-trigger="change"
       hx-validate="true"
       hx-target="#estado-email">

Validación en el servidor: el patrón completo

La validación real vive en el servidor. El patrón idiomático de htmx 4 usa el ciclo “devolver el formulario con los errores dentro”, combinado con morphing para no perder el foco:

<form id="registro"
      hx-post="/registro"
      hx-target="this"
      hx-swap="outerMorph">
  <div class="campo">
    <label>Email</label>
    <input name="email" value="">
  </div>
  <div class="campo">
    <label>RUT</label>
    <input name="rut" value="">
  </div>
  <button type="submit">Registrarse</button>
</form>
function renderFormulario(valores = {}, errores = {}) {
  const campo = (nombre, etiqueta) => `
    <div class="campo ${errores[nombre] ? "con-error" : ""}">
      <label>${etiqueta}</label>
      <input name="${nombre}" value="${escapar(valores[nombre] ?? "")}">
      ${errores[nombre] ? `<span class="error">${errores[nombre]}</span>` : ""}
    </div>`;

  return `
    <form id="registro" hx-post="/registro" hx-target="this" hx-swap="outerMorph">
      ${campo("email", "Email")}
      ${campo("rut", "RUT")}
      <button type="submit">Registrarse</button>
    </form>`;
}

// POST /registro
const datos = Object.fromEntries(await req.formData());
const errores = validar(datos);

if (Object.keys(errores).length > 0) {
  return new Response(renderFormulario(datos, errores), {
    status: 422,
    headers: { "Content-Type": "text/html" },
  });
}

crearUsuario(datos);
return html(`<p class="ok">Cuenta creada ✓</p>`);

Dos detalles importantes:

  1. El 422 se inserta. En htmx 2 había que interceptar htmx:beforeSwap para permitirlo; en htmx 4 todos los códigos hacen swap salvo 204 y 304.
  2. outerMorph en lugar de outerHTML: el usuario no pierde el cursor ni el scroll cuando vuelve el formulario con los errores.
sequenceDiagram
    participant U as Usuario
    participant F as Formulario
    participant S as Servidor
    U->>F: submit
    F->>S: POST /registro
    alt datos inválidos
        S-->>F: 422 + formulario con errores
        F->>F: outerMorph (foco intacto)
    else datos válidos
        S-->>F: 200 + confirmación
    end

Validación en vivo por campo

<input name="rut"
       hx-post="/validar/rut"
       hx-trigger="change, blur"
       hx-target="next .error"
       hx-swap="innerHTML">
<span class="error"></span>

El servidor devuelve el mensaje o una cadena vacía. Recuerda swapEmpty:true si quieres que la respuesta vacía limpie el mensaje anterior:

hx-swap="innerHTML swapEmpty:true"

Filtrar parámetros

hx-params fue eliminado en htmx 4. Para excluir parámetros de la petición se usa el evento htmx:config:request:

<form hx-post="/guardar"
      hx-on:htmx:config:request="ctx.request.body.delete('campo_interno')">

</form>

O globalmente:

htmx.on("htmx:config:request", (e) => {
  e.detail.ctx.request.body.delete("_debug");
});

El cuerpo es un FormData estándar, así que tienes get, set, append, delete y entries.

Reset del formulario tras enviar

Un caso clásico: enviar un mensaje y limpiar el campo.

<form hx-post="/mensajes"
      hx-target="#chat"
      hx-swap="beforeend"
      hx-on:htmx:after:request="this.reset()">
  <input name="texto" autocomplete="off">
  <button type="submit">Enviar</button>
</form>

Si quieres limpiarlo sólo cuando la petición fue exitosa:

hx-on:htmx:after:request="if (ctx.response.status < 400) this.reset()"

Errores comunes

SíntomaCausaSolución
El archivo llega vacíoFalta hx-encodinghx-encoding="multipart/form-data"
El hx-delete no manda datosCambio de htmx 4hx-include="closest form"
El 422 no se veSe está usando config de htmx 2 (noSwap)Revisar htmx.config.noSwap
Se pierde el cursor al validarouterHTML recrea el inputouterMorph
hx-vals con js: no evalúaCSP sin unsafe-evalUsar htmx:config:request
hx-vars no funcionaFue eliminado en htmx 4hx-vals con js:
hx-params no funcionaFue eliminado en htmx 4htmx:config:request + body.delete()

Resumen

  • hx-vals añade valores; con js: los calcula en el momento (reemplaza a hx-vars).
  • hx-include suma los campos de cualquier otro elemento, con selectores extendidos.
  • hx-headers con :inherited es la forma limpia de propagar un token CSRF.
  • hx-encoding="multipart/form-data" para subir archivos.
  • La validación de cliente es la nativa de HTML5; hx-validate la activa fuera de formularios.
  • El patrón de validación de servidor es: 422 + formulario con errores + outerMorph.
  • hx-params fue eliminado; se sustituye por htmx:config:request.

Siguiente: Eventos y scripting →.