Peticiones: hx-get, hx-post y familia
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(omultipart/form-datasi usashx-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:
| Header | Ejemplo | Significado |
|---|---|---|
HX-Request | true | La petición viene de htmx |
HX-Request-Type | partial / full | Si se espera un fragmento o un documento |
HX-Current-URL | https://app.com/tareas | URL actual del navegador |
HX-Source | button#guardar | Elemento que originó la petición |
HX-Target | div#lista | Elemento destino |
HX-Boosted | true | La petición viene de hx-boost |
HX-History-Restore-Request | true | Es 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
204y304. Un204 No Contentes 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íntoma | Causa | Solución |
|---|---|---|
Se inserta [object Object] o JSON crudo | El servidor devuelve JSON | Devuelve HTML con Content-Type: text/html |
El hx-delete no manda los campos del form | Cambio de htmx 4 | Añade hx-include="closest form" |
La página entera aparece dentro de un <div> | El servidor no distingue fragmento de página | Usa el header HX-Request |
| La petición nunca sale | El elemento no tiene un trigger válido | Ver el capítulo 4 |
Un <button> dentro de un form recarga la página | Es type="submit" por defecto | Añ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-deleteya no incluye automáticamente el formulario contenedor. hx-action+hx-methodseparan URL y verbo cuando conviene.htmx.ajax()dispara peticiones desde JavaScript y devuelve una promesa.- El header
HX-Requestte permite servir página completa o fragmento desde una misma ruta.
Siguiente: Triggers →.