Peticiones: hx-get, hx-post y familia

Por: Artiko
htmxhtmx4httppeticionesheaders

Peticiones: hx-get, hx-post y familia

Cada petición de htmx nace de un atributo que dice qué verbo y a qué URL. Son cinco:

<button hx-get="/usuarios">Cargar usuarios</button>
<button hx-post="/usuarios">Crear usuario</button>
<button hx-put="/usuarios/1">Reemplazar usuario</button>
<button hx-patch="/usuarios/1">Modificar usuario</button>
<button hx-delete="/usuarios/1">Eliminar usuario</button>

htmx emite la petición y coloca el HTML de la respuesta en el target. El verbo importa: define la semántica en el servidor y también cómo viajan los parámetros.

Cómo se arman los parámetros

Esta es la parte que más confunde al empezar. htmx recolecta valores de tres fuentes y los envía según el verbo.

flowchart TD
    A[Elemento dispara la petición] --> B{¿Es un form<br/>o está dentro de uno?}
    B -->|Sí| C[Toma todos los inputs del form]
    B -->|No, es un input| D[Toma su propio valor]
    B -->|No| E[Sin valores propios]
    C --> F[Suma hx-vals]
    D --> F
    E --> F
    F --> G[Suma hx-include]
    G --> H{¿Verbo?}
    H -->|GET| I[Query string en la URL]
    H -->|POST/PUT/PATCH/DELETE| J[Cuerpo como FormData]

Reglas concretas:

  • GET manda todo en el query string.
  • POST, PUT, PATCH mandan todo en el cuerpo, codificado como application/x-www-form-urlencoded (o multipart/form-data si usas hx-encoding).
  • Un <form> aporta todos sus campos.
  • Un <input>, <select> o <textarea> que dispara la petición aporta su propio valor.
  • Cualquier otro elemento no aporta valores por sí solo.

Cambio importante en htmx 4: hx-delete

En htmx 2, un hx-delete dentro de un formulario incluía automáticamente los campos de ese formulario. En htmx 4 no lo hace. Si los necesitas, pídelos explícitamente:

<form>
  <input name="motivo" value="spam">
  <button hx-delete="/comentarios/42" hx-include="closest form">
    Eliminar
  </button>
</form>

Ejemplo completo de los cinco verbos

Servidor:

const html = (c) => new Response(c, {
  headers: { "Content-Type": "text/html; charset=utf-8" },
});

const usuarios = new Map([[1, { id: 1, nombre: "Ana", rol: "admin" }]]);
let siguienteId = 2;

const fila = (u) => `
  <tr id="usuario-${u.id}">
    <td>${u.nombre}</td>
    <td>${u.rol}</td>
    <td>
      <button hx-patch="/usuarios/${u.id}"
              hx-vals='{"rol":"editor"}'
              hx-target="#usuario-${u.id}"
              hx-swap="outerHTML">Degradar</button>
      <button hx-delete="/usuarios/${u.id}"
              hx-target="#usuario-${u.id}"
              hx-swap="delete">Eliminar</button>
    </td>
  </tr>`;

Bun.serve({
  port: 3000,
  async fetch(req) {
    const { pathname } = new URL(req.url);
    const m = pathname.match(/^\/usuarios\/(\d+)$/);

    if (pathname === "/usuarios" && req.method === "GET") {
      return html([...usuarios.values()].map(fila).join(""));
    }

    if (pathname === "/usuarios" && req.method === "POST") {
      const datos = await req.formData();
      const u = { id: siguienteId++, nombre: datos.get("nombre"), rol: "lector" };
      usuarios.set(u.id, u);
      return html(fila(u));
    }

    if (m && req.method === "PATCH") {
      const datos = await req.formData();
      const u = usuarios.get(Number(m[1]));
      u.rol = datos.get("rol");
      return html(fila(u));
    }

    if (m && req.method === "DELETE") {
      usuarios.delete(Number(m[1]));
      return new Response(null, { status: 200 });
    }

    return new Response(Bun.file("./index.html"));
  },
});

Cliente:

<form hx-post="/usuarios" hx-target="#tabla tbody" hx-swap="beforeend">
  <input name="nombre" placeholder="Nombre" required>
  <button type="submit">Crear</button>
</form>

<table id="tabla">
  <tbody hx-get="/usuarios" hx-trigger="load"></tbody>
</table>

Fíjate en el patrón: cada acción del servidor devuelve exactamente el fragmento que hay que insertar, no la página entera. El POST devuelve un <tr> que se añade al final del <tbody>; el PATCH devuelve el <tr> actualizado que reemplaza al viejo.

hx-action y hx-method

A veces la URL o el verbo son dinámicos, o vienen de un formulario ya existente. htmx 4 permite separarlos:

<button hx-method="post" hx-action="/usuarios">Crear</button>

Es equivalente a hx-post="/usuarios". Sirve sobre todo cuando generas el HTML desde el servidor y te resulta más limpio emitir dos atributos independientes que concatenar el nombre del atributo, o cuando quieres cambiar el verbo desde JavaScript sin borrar y recrear atributos.

Peticiones desde JavaScript: htmx.ajax()

Cuando necesitas disparar una petición sin un elemento que la origine:

htmx.ajax("GET", "/usuarios", { target: "#tabla tbody", swap: "innerHTML" });

Devuelve una promesa que resuelve cuando el swap terminó:

await htmx.ajax("POST", "/usuarios", {
  target: "#tabla tbody",
  swap: "beforeend",
  values: { nombre: "Marta" },
});
console.log("fila insertada");

Headers que envía htmx

Toda petición de htmx 4 lleva estos headers, y el servidor puede usarlos para decidir qué devolver:

HeaderEjemploSignificado
HX-RequesttrueLa petición viene de htmx
HX-Request-Typepartial / fullSi se espera un fragmento o un documento
HX-Current-URLhttps://app.com/tareasURL actual del navegador
HX-Sourcebutton#guardarElemento que originó la petición
HX-Targetdiv#listaElemento destino
HX-BoostedtrueLa petición viene de hx-boost
HX-History-Restore-RequesttrueEs una restauración de historial

HX-Source y HX-Target usan el formato tagName#id en htmx 4 (en htmx 2 era sólo el id).

El patrón más útil: una ruta, dos respuestas

function esFragmento(req) {
  return req.headers.get("HX-Request") === "true";
}

if (pathname === "/tareas") {
  const lista = renderLista(tareas);
  return html(esFragmento(req) ? lista : paginaCompleta(lista));
}

La misma URL sirve la página completa cuando alguien la abre directamente o la comparte, y sólo el fragmento cuando la pide htmx. Eso mantiene tus URLs reales y compartibles sin duplicar rutas.

sequenceDiagram
    participant N as Navegación directa
    participant X as htmx
    participant S as Servidor /tareas
    N->>S: GET /tareas
    S-->>N: documento completo (layout + lista)
    X->>S: GET /tareas + HX-Request: true
    S-->>X: sólo <ul> con las tareas

Qué respuestas espera htmx

  • Content-Type: text/html. Si devuelves JSON, htmx lo insertará como texto plano, que casi nunca es lo que quieres.
  • Código de estado: en htmx 4 se hace swap con todos los códigos excepto 204 y 304. Un 204 No Content es la forma canónica de decir “todo bien, no cambies nada”.
  • Cuerpo vacío: por defecto htmx no hace swap con un cuerpo vacío; para forzarlo, hx-swap="innerHTML swapEmpty:true".

Errores comunes

SíntomaCausaSolución
Se inserta [object Object] o JSON crudoEl servidor devuelve JSONDevuelve HTML con Content-Type: text/html
El hx-delete no manda los campos del formCambio de htmx 4Añade hx-include="closest form"
La página entera aparece dentro de un <div>El servidor no distingue fragmento de páginaUsa el header HX-Request
La petición nunca saleEl elemento no tiene un trigger válidoVer el capítulo 4
Un <button> dentro de un form recarga la páginaEs type="submit" por defectoAñade type="button"

Ese último merece énfasis: dentro de un <form>, todo <button> sin type es un botón de envío. Si le pones hx-delete, se disparan dos cosas a la vez: la petición de htmx y el submit nativo.

Resumen

  • Cinco atributos de verbo: hx-get, hx-post, hx-put, hx-patch, hx-delete.
  • GET manda los parámetros en la URL; el resto, en el cuerpo.
  • En htmx 4, hx-delete ya no incluye automáticamente el formulario contenedor.
  • hx-action + hx-method separan URL y verbo cuando conviene.
  • htmx.ajax() dispara peticiones desde JavaScript y devuelve una promesa.
  • El header HX-Request te permite servir página completa o fragmento desde una misma ruta.

Siguiente: Triggers →.