Skip to content

🎯 Meta: conseguir interfaces interactivas (buscar sin recargar, formularios en vivo, scroll infinito…) devolviendo HTML desde tu backend, sin construir una SPA ni escribir apenas JavaScript. HTMX es la opción más productiva cuando ya tienes un backend Laravel, Flask, Go o NestJS (caps. 03-08).

Versiones: HTMX 2.0.9 — última estable (20 abril 2026). HTMX 4.0.0-beta6 — última pre-release (23 julio 2026). No hay versión 3: se pasó directamente de la 2 a la 4.

⚠️ Qué versión usar hoy: la 2.0.9. La rama 4.0 lleva seis betas y todavía no ha alcanzado la estabilidad, así que no es para producción. Este capítulo enseña HTMX 2, y la mayoría de lo que aprendas aquí (los verbos, hx-target, hx-swap, hx-trigger, el modelo de hipermedia) es idéntico en la 4. Lee la sección 16.14 antes de plantearte migrar.


16.1 · La filosofía: hipermedia en vez de JSON

Una SPA (React/Vue) funciona así: el servidor envía JSON → JavaScript lo convierte en HTML. HTMX elimina el paso intermedio: el servidor envía directamente el HTML y HTMX lo inserta donde toque.

   SPA (React/Vue)                        HTMX
┌──────────────────────┐         ┌──────────────────────┐
│ GET /api/productos   │         │ GET /productos/lista │
│  ◀── JSON            │         │  ◀── <li>…</li> HTML │
│ JS construye el DOM  │         │ HTMX lo inserta y ya │
└──────────────────────┘         └──────────────────────┘
  estado en el cliente             estado en el servidor

Cuándo usar HTMX ✔ apps CRUD, paneles de administración, dashboards, webs con formularios, equipos backend sin especialistas frontend. Cuándo NO ✘ apps con estado de cliente muy rico (editores tipo Figma, mapas interactivos, apps offline). Para eso, caps. 17-19.

🧠 HTMX extiende lo que HTML ya hacía: un <a> hace GET y reemplaza la página entera; HTMX permite que cualquier elemento haga cualquier petición y reemplace cualquier trozo de la página. Eso es todo el framework.


16.2 · Instalación y primer ejemplo

html
<!-- Opción 1: CDN (fija SIEMPRE la versión exacta) -->
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.9/dist/htmx.min.js"
        integrity="sha384-..." crossorigin="anonymous"></script>
bash
# Opción 2: npm (con Vite o cualquier bundler)
npm install htmx.org@2.0.9
javascript
// main.js (solo si usas npm)
import 'htmx.org';

Primer ejemplo — un botón que carga contenido:

html
<button hx-get="/saludo" hx-target="#resultado" hx-swap="innerHTML">
  Saludar
</button>
<div id="resultado"></div>

El backend (aquí Flask, pero vale cualquiera) devuelve un fragmento HTML, no JSON:

python
@app.get("/saludo")
def saludo():
    return "<p>¡Hola desde el servidor! 👋</p>"

Al hacer click: HTMX hace GET /saludo por AJAX → toma la respuesta HTML → la mete dentro de #resultado. Sin JavaScript propio.


16.3 · Los 4 atributos que son el 90% de HTMX

1. El verbo: hx-get, hx-post, hx-put, hx-patch, hx-delete

html
<button hx-get="/productos/lista">Recargar</button>
<button hx-delete="/productos/42">Borrar</button>
<form   hx-post="/productos">…</form>

2. El destino: hx-target (dónde se inserta la respuesta)

html
<!-- Selector CSS, o relativo al elemento -->
<button hx-get="/detalle/7" hx-target="#panel">Ver</button>
<button hx-delete="/items/7" hx-target="closest li">Borrar este item</button>
<!-- closest, next, previous, this, find <selector> -->

Sin hx-target, la respuesta reemplaza el contenido del propio elemento que disparó.

3. El modo de inserción: hx-swap

ValorEfecto
innerHTML (defecto)Reemplaza el contenido del target
outerHTMLReemplaza el target entero (clave para borrar/reemplazar filas)
beforeendAñade al final del target (listas, scroll infinito)
afterbeginAñade al principio (feeds de novedades)
beforebegin / afterendInserta como hermano antes/después
deleteElimina el target (ignora la respuesta)
noneNo inserta nada (útil con eventos u hx-swap-oob)
html
<!-- Borrar una fila: DELETE y la <li> desaparece -->
<li>
  Teclado mecánico
  <button hx-delete="/productos/42" hx-target="closest li" hx-swap="outerHTML"
          hx-confirm="¿Seguro que quieres borrarlo?">🗑</button>
</li>

(si el backend devuelve vacío con 200, el outerHTML deja la fila en nada — borrada)

4. El disparador: hx-trigger

html
<!-- Por defecto: click en botones, submit en forms, change en selects -->
<button hx-get="/hora">…</button>                          <!-- click -->

<!-- Búsqueda en vivo: al teclear, con espera y solo si cambió -->
<input type="search" name="q"
       hx-get="/productos/buscar"
       hx-trigger="input changed delay:300ms"
       hx-target="#resultados">

<!-- Polling: cada 5 segundos -->
<div hx-get="/pedidos/pendientes" hx-trigger="every 5s">…</div>

<!-- Al entrar en pantalla (lazy load) -->
<div hx-get="/grafico-pesado" hx-trigger="revealed">Cargando…</div>

<!-- Al cargar la página -->
<div hx-get="/notificaciones" hx-trigger="load">…</div>

Modificadores útiles: once (solo una vez), changed (solo si cambió el valor), delay:300ms (debounce), throttle:1s, from:body (escucha el evento en otro elemento).


16.4 · Formularios con HTMX

El caso estrella. Un formulario que crea un producto y actualiza la lista, con errores de validación renderizados por el servidor:

html
<form hx-post="/productos" hx-target="#lista" hx-swap="beforeend"
      hx-on::after-request="if(event.detail.successful) this.reset()">
  <input name="nombre" placeholder="Nombre" required>
  <input name="precio" type="number" step="0.01" required>
  <button type="submit">
    Crear
    <span class="htmx-indicator">⏳</span>   <!-- visible solo durante la petición -->
  </button>
</form>

<ul id="lista">
  <!-- el servidor añade <li> aquí -->
</ul>
<div id="errores"></div>

Backend (FastAPI) — fíjate: recibe form data (no JSON) y responde HTML:

python
from fastapi import FastAPI, Form
from fastapi.responses import HTMLResponse

@app.post("/productos", response_class=HTMLResponse)
def crear(nombre: str = Form(...), precio: float = Form(...)):
    if precio <= 0:
        # 422 + fragmento de error. retarget: HTMX lo pone en #errores
        return HTMLResponse(
            '<p class="error">El precio debe ser mayor que 0</p>',
            status_code=422,
            headers={"HX-Retarget": "#errores", "HX-Reswap": "innerHTML"},
        )
    p = repo.crear(nombre, precio)
    return f"<li>{p.nombre}{p.precio:.2f} €</li>"

🧠 Plantillas parciales: en la práctica no concatenas strings: renderizas partials con el motor de plantillas de tu framework (Blade en Laravel, Jinja en Flask, html/template en Go). El mismo partial sirve para la página completa y para la respuesta HTMX.

python
# Flask + Jinja: la misma plantilla parcial para todo
@app.get("/productos/buscar")
def buscar():
    q = request.args.get("q", "")
    productos = repo.buscar(q)
    return render_template("partials/lista_productos.html", productos=productos)

16.5 · Indicadores de carga y UX

html
<style>
  .htmx-indicator { display: none; }
  .htmx-request .htmx-indicator { display: inline; }      /* durante la petición */
  .htmx-request.boton { opacity: .6; pointer-events: none; }
</style>

<button class="boton" hx-post="/comprar" hx-disabled-elt="this">
  Comprar <span class="htmx-indicator">⏳</span>
</button>
  • hx-disabled-elt="this" deshabilita el botón durante la petición (evita doble click).
  • hx-indicator="#spinner" si el indicador está en otro sitio.
  • HTMX añade la clase htmx-request al elemento mientras la petición está en vuelo.

16.6 · Patrones completos

Scroll infinito

html
<tbody id="filas">
  <!-- …filas… la ÚLTIMA fila de cada página incluye el disparador: -->
  <tr hx-get="/productos?page=2" hx-trigger="revealed" hx-swap="afterend">
    <td>Producto 20</td>
  </tr>
</tbody>

Cada página que devuelve el servidor incluye en su última fila el hx-get de la siguiente.

html
<button hx-get="/productos/42/editar" hx-target="#modal" hx-swap="innerHTML">✏️ Editar</button>
<div id="modal"></div>
<!-- El servidor devuelve el <dialog> con el form; el form hace hx-put y al éxito
     devuelve la fila actualizada con hx-swap-oob (ver 16.7) y cierra el modal -->

Tabs sin JavaScript

html
<nav>
  <button hx-get="/panel/ventas"  hx-target="#panel" class="activa">Ventas</button>
  <button hx-get="/panel/stock"   hx-target="#panel">Stock</button>
</nav>
<div id="panel" hx-get="/panel/ventas" hx-trigger="load"></div>

hx-boost: mejora progresiva de toda la web

html
<body hx-boost="true">
  <!-- Todos los <a> y <form> normales pasan a ser AJAX (sin recarga completa),
       manteniendo URLs e historial. Si JS falla, funcionan como HTML normal. -->
</body>

16.7 · Herramientas avanzadas

Out-of-band swaps: actualizar VARIOS sitios con una respuesta

html
<!-- Respuesta del servidor a POST /carrito: -->
<li>Producto añadido</li>                              <!-- va al target normal -->
<span id="contador-carrito" hx-swap-oob="true">3</span> <!-- actualiza el badge del header -->

Cabeceras de respuesta HX-* (el servidor manda)

CabeceraEfecto
HX-Redirect: /loginRedirección completa del navegador
HX-Refresh: trueRecarga la página
HX-Retarget / HX-ReswapCambia target/swap de esta respuesta
HX-Trigger: pedidoCreadoDispara un evento JS en el cliente
HX-Push-Url: /productos/42Añade URL al historial
html
<!-- Escuchar el evento disparado por el servidor: recargar la lista -->
<ul id="lista" hx-get="/productos/lista" hx-trigger="pedidoCreado from:body">…</ul>

Peticiones: qué se envía

  • Formularios: todos sus campos. Otros elementos: su name/value si lo tienen.
  • hx-include="#otro-input" añade campos externos.
  • hx-vals='{"extra": 1}' añade valores fijos (JSON).
  • hx-params="none|*|lista" filtra qué parámetros van.
  • HTMX envía cabecera HX-Request: true → tu backend distingue peticiones HTMX de las normales (para devolver parcial o página completa).
python
# Patrón "parcial o página completa" (Flask)
def lista_productos():
    productos = repo.todos()
    plantilla = "partials/lista.html" if request.headers.get("HX-Request") else "productos.html"
    return render_template(plantilla, productos=productos)

Historial y URLs

html
<a hx-get="/productos/42" hx-target="#main" hx-push-url="true">Ver producto</a>
<!-- La URL cambia a /productos/42; atrás/adelante funcionan; F5 debe devolver
     la página completa (por eso el patrón HX-Request de arriba es importante) -->

Extensiones: hx-ext

HTMX 2 mantiene el núcleo pequeño y mueve funcionalidad opcional a extensiones que se activan por atributo. Las dos más usadas:

html
<!-- sse: el servidor empuja actualizaciones por Server-Sent Events (conecta con el cap. 21) -->
<script src="https://cdn.jsdelivr.net/npm/htmx-ext-sse@2.2.2"></script>
<div hx-ext="sse" sse-connect="/eventos/pedidos" sse-swap="nuevoPedido">
  <!-- cada evento SSE "nuevoPedido" reemplaza este div con el HTML que traiga -->
</div>

<!-- response-targets: define un target distinto SEGÚN el código de estado de la respuesta -->
<form hx-post="/productos" hx-target="#lista"
      hx-ext="response-targets" hx-target-422="#errores">
  <!-- éxito (2xx) va a #lista; un 422 va a #errores — sin escribir HX-Retarget en el backend -->
</form>

💡 Cuándo usar la extensión SSE en vez de hx-trigger="every Ns" (polling, 16.3): el polling pregunta aunque no haya nada nuevo; SSE mantiene una conexión abierta y el servidor empuja solo cuando ocurre algo — mismo trade-off que en el apéndice D.2 y el capítulo 21, aplicado aquí sin escribir una línea de JavaScript.


16.8 · Atributos de UX: los que hacen que se sienta como una app

Los cuatro atributos de la sección 16.3 cubren el 90% de la funcionalidad. Estos cubren el 90% de la sensación de calidad.

hx-confirm — confirmar antes de una acción destructiva

html
<button hx-delete="/pedidos/42"
        hx-confirm="¿Seguro que quieres cancelar este pedido? No se puede deshacer."
        hx-target="closest tr" hx-swap="outerHTML swap:0.3s">
  Cancelar pedido
</button>

hx-disabled-elt — impedir el doble clic

html
<form hx-post="/pedidos" hx-disabled-elt="find button">
  <button>Confirmar compra</button>
</form>

⚠️ Deshabilitar el botón es una mejora de interfaz, no una garantía. Un usuario con la consola abierta, una conexión lenta que reintenta o un bot pueden enviar el formulario dos veces igualmente. La protección real es la idempotencia en el servidor (capítulo 24): una clave de idempotencia o una restricción UNIQUE en la base de datos. El hx-disabled-elt evita el 99% de los casos accidentales; el otro 1% te lo cobra el banco.

hx-sync — qué hacer cuando llegan peticiones solapadas

html
<!-- Buscador: cada tecla dispara una petición. Sin hx-sync, las respuestas
     pueden llegar desordenadas y mostrar resultados de una búsqueda antigua. -->
<input name="q" hx-get="/buscar" hx-trigger="keyup changed delay:300ms"
       hx-target="#resultados"
       hx-sync="this:replace">     <!-- cancela la petición anterior y lanza la nueva -->
ValorComportamiento
this:dropIgnora la nueva si ya hay una en curso
this:abortAborta la nueva
this:replace📌 Cancela la en curso y lanza la nueva (buscadores)
closest form:queueLas encola en orden

hx-vals y hx-headers — enviar datos extra

html
<button hx-post="/tareas/42/estado"
        hx-vals='{"estado": "hecha", "origen": "listado"}'>Completar</button>

<div hx-headers='{"X-CSRF-Token": "abc123"}'>…</div>   <!-- se hereda a los hijos -->

hx-preserve — que un elemento sobreviva al reemplazo

html
<!-- Un vídeo reproduciéndose no debe reiniciarse porque se actualizó la página -->
<video id="reproductor" hx-preserve="true" controls src="/clase.mp4"></video>

Manejo de errores (lo que casi nadie configura)

Por defecto, HTMX no inserta nada si la respuesta es 4xx o 5xx: el usuario pulsa y no ocurre absolutamente nada. Es el fallo de UX más común en aplicaciones HTMX.

html
<!-- Opción 1: que los errores de validación (422) SÍ se inserten -->
<form hx-post="/usuarios" hx-target="#formulario"
      hx-target-422="#formulario">    <!-- requiere la extensión response-targets -->
javascript
// Opción 2: manejo global. Ponlo una vez y olvídate.
document.body.addEventListener('htmx:responseError', (e) => {
  mostrarToast(`Error ${e.detail.xhr.status}: no se pudo completar la acción`);
});

document.body.addEventListener('htmx:sendError', () => {
  mostrarToast('Sin conexión. Comprueba tu red e inténtalo de nuevo.');
});

document.body.addEventListener('htmx:timeout', () => {
  mostrarToast('La operación tardó demasiado.');
});
javascript
htmx.config.timeout = 10000;   // ⚠️ por defecto NO hay timeout: se espera para siempre

💡 Los tres listeners de arriba y el timeout son el mínimo que debe tener cualquier aplicación HTMX en producción. Son 10 líneas que convierten "la app a veces no hace nada" en "la app te dice qué pasó".


16.9 · HTMX con cada backend del libro

La gracia de HTMX es que el servidor devuelve fragmentos de HTML, no JSON. Así se hace en cada stack del libro — fíjate en que el patrón es idéntico y solo cambia la sintaxis.

El patrón universal:

1. ¿La petición viene de HTMX?  → mira la cabecera HX-Request
2. Si SÍ  → devuelve solo el FRAGMENTO (el trozo de HTML que cambia)
3. Si NO  → devuelve la PÁGINA COMPLETA (navegación directa, buscadores, F5)

Eso te da mejora progresiva gratis: la web funciona igual sin JavaScript.

php
// Laravel (capítulo 3)
public function index(Request $r) {
    $tareas = Tarea::where('usuario_id', auth()->id())->latest()->paginate(20);
    return $r->header('HX-Request')
        ? view('tareas.partials.lista', compact('tareas'))   // fragmento
        : view('tareas.index',          compact('tareas'));  // página completa
}
python
# FastAPI + Jinja2 (capítulo 5)
@app.get("/tareas", response_class=HTMLResponse)
def listar(request: Request, hx_request: str | None = Header(None)):
    tareas = repo.listar(usuario_actual.id)
    plantilla = "partials/lista.html" if hx_request else "tareas.html"
    return templates.TemplateResponse(plantilla, {"request": request, "tareas": tareas})
go
// Go + html/template (capítulo 8)
func listar(w http.ResponseWriter, r *http.Request) {
    tareas := repo.Listar(usuarioDe(r))
    plantilla := "tareas.html"
    if r.Header.Get("HX-Request") == "true" {
        plantilla = "partials/lista.html"
    }
    tmpl.ExecuteTemplate(w, plantilla, tareas)
}

🧠 Este patrón revela por qué HTMX encaja tan bien con los backends "clásicos". No necesitas una API JSON, ni un cliente que la consuma, ni tipos compartidos entre front y back, ni un proceso de build. Tu servidor ya sabía generar HTML: HTMX solo le pide trozos más pequeños. Para un equipo pequeño, eso es una capa entera de complejidad que desaparece.

💡 Organiza las plantillas para que el fragmento sea reutilizable: la página completa incluye el mismo fragmento que devuelves a HTMX. Una sola fuente de verdad para ese HTML:

tareas.html          →  layout + {% include "partials/lista.html" %}
partials/lista.html  →  solo el <ul> con las tareas   ← lo que devuelve HTMX

16.10 · Un pellizco de JavaScript: Alpine.js

Para interactividad puramente de cliente (abrir/cerrar un menú, un contador local) no necesitas ir al servidor. El compañero natural de HTMX es Alpine.js (~15 KB):

html
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js" defer></script>

<div x-data="{ abierto: false }">
  <button @click="abierto = !abierto">Menú</button>
  <nav x-show="abierto" @click.outside="abierto = false">…</nav>
</div>

💡 Regla: estado del negocio → servidor (HTMX); estado de la UI → Alpine. Si te encuentras replicando datos del servidor en Alpine, estás construyendo una SPA sin querer: pásate a los caps. 17-19.


16.11 · Seguridad con HTMX

  1. Escapa SIEMPRE en el servidor. Devuelves HTML: cualquier dato de usuario sin escapar es XSS directo. Los motores de plantillas (Jinja, Blade, html/template) escapan por defecto — no lo desactives (|safe, {!! !!}) con datos de usuario.
  2. CSRF: las peticiones HTMX son peticiones normales; usa el token de tu framework:
html
<body hx-headers='{"X-CSRF-Token": "{{ csrf_token() }}"}'>
  1. No confíes en hx-* del cliente: cualquiera edita el DOM. Autoriza cada endpoint en el backend como siempre (apéndice C).
  2. CSP: HTMX 2 funciona con Content-Security-Policy estricta; evita hx-on inline si tu CSP prohíbe unsafe-eval/inline (configura htmx.config.allowEval = false y prueba).

16.12 · Tests

Con HTMX, la lógica vive en el servidor → la testeas con las herramientas de tu backend (cap. 09). Verifica que los endpoints devuelven el HTML esperado:

python
# pytest + Flask
def test_buscar_devuelve_parcial(client):
    resp = client.get("/productos/buscar?q=teclado", headers={"HX-Request": "true"})
    assert resp.status_code == 200
    assert b"<li" in resp.data
    assert b"Teclado" in resp.data

def test_precio_invalido_devuelve_error(client):
    resp = client.post("/productos", data={"nombre": "X", "precio": "-1"})
    assert resp.status_code == 422
    assert "error" in resp.headers.get("HX-Retarget", "#errores")

Para flujos completos (click → swap → resultado visible) usa Playwright (cap. 09, e2e).


16.13 · Buenas prácticas HTMX

  1. Empieza con HTML que funcione sin HTMX (mejora progresiva); hx-boost es tu amigo.
  2. Partials reutilizables: la misma plantilla para carga completa y respuesta HTMX.
  3. Detecta HX-Request para devolver parcial o página completa (F5 debe funcionar).
  4. Estados de carga siempre: htmx-indicator + hx-disabled-elt en todo lo que escriba.
  5. hx-confirm en acciones destructivas.
  6. Fija la versión exacta del script (nada de htmx.org@latest) y usa SRI en CDN.
  7. Códigos HTTP correctos: 422 para validación, 4xx/5xx con HX-Retarget a la zona de error.
  8. No reinventes una SPA: si el servidor ya no es la fuente de verdad de la UI, cambia de herramienta (caps. 17-19).

16.14 · HTMX 4: qué viene y cuándo saltar

HTMX 4 lleva en desarrollo desde 2024 y va por la beta 6 (23 julio 2026). No hubo versión 3: se pasó de la 2 a la 4 directamente. El anuncio oficial se titula "The fetch()ening", y ese nombre resume el cambio de fondo.

Los tres cambios que rompen cosas

1. fetch() en lugar de XMLHttpRequest. El núcleo se reescribe sobre la API moderna del navegador. Como consecuencia cambia el modelo de eventos: si tu código escucha eventos de HTMX (htmx:beforeRequest, htmx:responseError…) o toca el objeto xhr, tendrás que revisarlo. Es el motivo principal de que sea una versión mayor.

2. La herencia de atributos pasa a ser explícita. En HTMX 2, poner hx-target en un <div> lo hereda todo lo que hay dentro, lo quieras o no — el mismo tipo de sorpresa que las cascadas de CSS. En la 4 hay que pedirla:

html
<!-- HTMX 2: se hereda implícitamente por todos los hijos -->
<div hx-target="#resultado"> … </div>

<!-- HTMX 4: la herencia se declara -->
<div hx-target:inherited="#resultado"> … </div>

3. El historial se simplifica. HTMX 2 guarda instantáneas del DOM en caché local para restaurar al pulsar "atrás", algo frágil cuando otro script ha modificado la página. La 4 elimina esas instantáneas y vuelve a pedir la página al servidor. Más predecible, a cambio de una petición de red.

Lo que se gana

NovedadQué resuelve
SSE y respuestas en streaming en el núcleoYa no hacen falta extensiones para tiempo real (cap. 21) ni para respuestas de LLM (ap. O)
Swaps con morphing (morphInner/morphOuter)Reemplaza el DOM conservando foco, scroll y estado de los inputs
Etiqueta <htmx-partial>Out-of-band swaps (sección 16.7) mucho más legibles
Cola de View Transitions mejoradaAnimaciones entre estados sin parpadeos
Nombres de eventos estandarizadosMenos casos especiales que memorizar
hx-on mejoradoScripting ligero más cómodo sin salir del HTML

La decisión práctica

💡 No migres todavía, y no tengas prisa cuando salga. El propio proyecto declara que htmx 2.0 tendrá soporte a perpetuidad, y el despliegue de la 4 está planteado a lo largo de varios años. Esto es lo contrario de la norma en el ecosistema JavaScript, y es deliberado: forma parte de la filosofía de htmx.

Aplicando el marco del capítulo 25:

¿Qué problema MÍO resuelve htmx 4?
  · Si usas SSE con extensiones y te dan problemas → tiene interés real.
  · Si te has peleado con la herencia implícita     → la 4 lo arregla.
  · Si tu app de htmx 2 funciona bien               → ninguno. No migres.

¿Es reversible?  Sí, pero con trabajo: el modelo de eventos cambia.
¿Qué antigüedad tiene?  Seis betas y aún sin fecha firme de estable.
                        Para producción, eso es un "todavía no".

🧠 Este es un caso de libro de "elegir tecnología aburrida" (25.4). Lo nuevo y brillante es htmx 4; lo correcto para casi todo el mundo hoy es htmx 2.0.9, que está estable, documentado y con soporte garantizado. La versión mayor de una dependencia no es un objetivo en sí mismo: migra cuando te resuelva un problema que tengas, no cuando salga.


✅ Ejercicio del capítulo

Panel de administración de productos sobre tu backend favorito (caps. 03-08):

1. Tabla de productos renderizada por el servidor (plantillas + partials).
2. Búsqueda en vivo (input + delay:300ms + changed) sin recargar.
3. Alta con formulario hx-post: éxito añade fila (beforeend), error 422 muestra
   mensaje del servidor con HX-Retarget.
4. Borrado con hx-delete + hx-confirm + outerHTML swap.
5. Edición en modal cargado con hx-get; al guardar, actualiza la fila con hx-swap-oob.
6. Contador "N productos" en el header actualizado por out-of-band en cada alta/baja.
7. Paginación con scroll infinito (revealed).
8. Tests de los endpoints parciales (HX-Request: true) con el framework de tests del backend.

Ya sabes exprimir el servidor. Ahora el enfoque opuesto: React, donde la UI vive por completo en el cliente.

💡 Pistas de la solución (abre solo si te atascas)
  • El punto 3 necesita que tu backend distinga "petición HTMX" de "petición normal" con la cabecera HX-Request (16.7) para devolver solo el partial en vez de la página completa.
  • Para el punto 5, el modal se cierra normalmente disparando un evento propio con HX-Trigger desde el servidor tras guardar, y un listener hx-trigger="cerrarModal from:body" en el propio <dialog>.
  • El contador out-of-band (punto 6) necesita un id estable en el <span> del header — si cambias ese id entre respuestas, el hx-swap-oob="true" no encuentra a quién reemplazar.
  • Recuerda: los tests del punto 8 deben mandar la cabecera HX-Request: true a mano (con tu cliente de test HTTP) para que el backend devuelva el partial y no la página completa.

🧠 Autoevaluación

HTMX pide HTML ya renderizado al servidor y lo inserta directamente donde le digas (hx-target/hx-swap); React reconstruye el árbol de componentes en el cliente a partir de JSON y lo reconcilia con el DOM. El estado de la UI vive en el servidor en un caso, y en el navegador en el otro.

outerHTML reemplaza el elemento entero (la <li> completa) por la respuesta del servidor; si el servidor responde vacío, la fila desaparece del todo. innerHTML solo reemplazaría el contenido DENTRO de la fila, dejando la <li> vacía pero presente — la lista quedaría con un hueco en blanco en vez de sin esa fila.

Sin delay, cada tecla dispara una petición HTTP — decenas por segundo mientras el usuario escribe. delay:300ms espera una pausa antes de disparar (debounce); changed evita peticiones repetidas si el valor no cambió realmente (por ejemplo al pulsar una tecla de flecha dentro del campo).

hx-boost intercepta enlaces y formularios normales para convertirlos en peticiones AJAX, pero cada respuesta sigue siendo HTML generado por el servidor — no hay estado de cliente ni componentes reactivos. Si JavaScript falla o se desactiva, los mismos enlaces y formularios siguen funcionando como HTML plano: es mejora progresiva, no una SPA.


Siguiente: 17-react.md — la librería de UI más usada del mundo.