Partials, OOB y selección de respuesta
Partials, OOB y selección de respuesta
Una acción del usuario suele tener que actualizar más de una zona de la página. Añadir un producto al carrito cambia la lista, el contador del header y el total. htmx 4 ofrece tres mecanismos para eso, del más nuevo al más antiguo.
flowchart TD
A[Una respuesta,<br/>varias zonas] --> B["<hx-partial><br/>nuevo en htmx 4"]
A --> C["hx-swap-oob<br/>heredado de htmx 2"]
A --> D["hx-select-oob<br/>desde el cliente"]
<hx-partial>: la forma nativa de htmx 4
Un <hx-partial> es un fragmento de la respuesta que declara su propio destino y su propia
estrategia de swap. Por debajo es un <template>, así que su contenido no se renderiza hasta que
htmx lo procesa.
Respuesta del servidor:
<hx-partial hx-target="#mensajes" hx-swap="beforeend">
<div class="mensaje">Producto añadido</div>
</hx-partial>
<hx-partial hx-target="#contador-carrito">
<span class="badge">5</span>
</hx-partial>
<hx-partial hx-target="#total">
$ 42.990
</hx-partial>
Cada uno viaja a su destino. Atributos que acepta:
| Atributo | Qué hace |
|---|---|
hx-target | Selector CSS del destino |
id | Atajo: equivale a hx-target="#<id>" |
hx-swap | Estrategia (por defecto innerHTML) |
La forma corta con id:
<hx-partial id="contador-carrito">
<span class="badge">5</span>
</hx-partial>
Regla clave
Si la respuesta contiene sólo elementos
<hx-partial>y nada más, htmx no hace el swap principal sobre el target original.
Eso permite acciones que actualizan tres zonas y ninguna de ellas es el elemento que disparó la petición:
// POST /carrito/agregar
return html(`
<hx-partial id="contador-carrito"><span class="badge">${carrito.items}</span></hx-partial>
<hx-partial id="total">$ ${formato(carrito.total)}</hx-partial>
<hx-partial hx-target="#avisos" hx-swap="afterbegin">
<div class="toast">Añadido al carrito</div>
</hx-partial>
`);
Y si además quieres actualizar el target principal, incluye contenido fuera de los partials:
<hx-partial id="contador-carrito"><span class="badge">5</span></hx-partial>
<!-- Esto va al hx-target del elemento que disparó -->
<button hx-post="/carrito/quitar/17">Quitar del carrito</button>
Alternativa con <template>
Algunos motores de plantillas, sanitizadores o herramientas de formateo eliminan las etiquetas desconocidas. Para esos casos existe la forma equivalente:
<template hx type="partial" hx-target="#mensajes" hx-swap="beforeend">
<div class="mensaje">Producto añadido</div>
</template>
Es exactamente lo mismo, escrito con un elemento estándar.
hx-swap-oob: swaps fuera de banda
El mecanismo clásico, que sigue funcionando. Un elemento de la respuesta marcado con
hx-swap-oob se coloca donde esté el elemento del documento con su mismo id:
<!-- Respuesta del servidor -->
<div id="mensaje" hx-swap-oob="true">Guardado correctamente</div>
<!-- Y este es el contenido que va al target normal -->
<form id="mi-form">…</form>
Variantes:
<!-- Cambiar la estrategia -->
<div id="notificaciones" hx-swap-oob="beforeend">
<span>Nueva notificación</span>
</div>
<!-- Apuntar a un selector distinto del id -->
<div hx-swap-oob="innerHTML:#estado">Procesando…</div>
<hx-partial> vs hx-swap-oob
| Aspecto | <hx-partial> | hx-swap-oob |
|---|---|---|
| Destino | Selector explícito | Por coincidencia de id |
El fragmento necesita id | No | Sí, y debe coincidir |
| Se renderiza si htmx no está | No (es un template) | Sí, queda contenido suelto |
| Estrategias | Todas | Todas |
| Anula el swap principal | Sí, si es lo único | No |
La diferencia práctica: con OOB, el HTML del fragmento tiene que llevar el id del destino, así
que estás copiando la estructura del documento dentro de la respuesta. Con <hx-partial>, el
destino es un dato del partial y el contenido queda limpio. Para código nuevo en htmx 4, prefiere
<hx-partial>.
hx-select: recortar la respuesta
Del lado del cliente, hx-select extrae una parte de la respuesta y descarta el resto:
<button hx-get="/reporte" hx-select="#tabla-resumen" hx-target="#panel">
Ver resumen
</button>
El servidor devuelve una página completa; htmx se queda sólo con #tabla-resumen.
Para qué sirve:
- Reutilizar páginas existentes como fuente de fragmentos, sin crear endpoints nuevos.
- Consumir HTML de un sistema legado que no puedes modificar.
- Prototipar antes de que el backend tenga endpoints de fragmentos.
No es lo más eficiente —viaja HTML que se descarta— pero desbloquea muchísimos casos.
hx-select-oob: OOB decidido en el cliente
Extrae elementos de la respuesta y los coloca fuera de banda, sin que el servidor tenga que marcarlos:
<button hx-post="/guardar"
hx-target="#formulario"
hx-select-oob="#mensaje, #contador">
Guardar
</button>
De la respuesta, #mensaje y #contador van a los elementos con esos ids en el documento, y el
resto va al target normal. Es el complemento de hx-select para respuestas de página completa.
Elegir el mecanismo
flowchart TD
A{¿Quién decide<br/>las zonas?} --> B[El servidor]
A --> C[El cliente]
B --> D{¿Código nuevo?}
D -->|Sí| E["<hx-partial>"]
D -->|Legado o htmx 2| F["hx-swap-oob"]
C --> G{¿Una zona o varias?}
G -->|Una| H["hx-select"]
G -->|Varias| I["hx-select-oob"]
Ejemplo completo: carrito de compras
<header>
<span id="contador-carrito">0</span> items —
<strong id="total-carrito">$ 0</strong>
</header>
<div id="avisos"></div>
<ul id="productos">
<li>
Café 1 kg — $ 8.990
<button hx-post="/carrito/agregar/1" hx-swap="none">Añadir</button>
</li>
<li>
Té verde — $ 4.500
<button hx-post="/carrito/agregar/2" hx-swap="none">Añadir</button>
</li>
</ul>
hx-swap="none" porque el botón no tiene nada que actualizar en sí mismo: todo llega por partials.
// POST /carrito/agregar/:id
function agregarAlCarrito(id) {
const carrito = agregar(id);
return html(`
<hx-partial id="contador-carrito">${carrito.items}</hx-partial>
<hx-partial id="total-carrito">$ ${formato(carrito.total)}</hx-partial>
<hx-partial hx-target="#avisos" hx-swap="afterbegin">
<div class="toast" hx-on:load="await timeout('3s'); this.remove()">
Producto añadido al carrito
</div>
</hx-partial>
`);
}
Ese hx-on:load con await timeout('3s') hace que el toast se autodestruya: lo vemos en detalle
en el capítulo 10.
sequenceDiagram
participant B as Botón "Añadir"
participant H as htmx
participant S as Servidor
participant C as #contador-carrito
participant T as #total-carrito
participant A as #avisos
B->>H: clic
H->>S: POST /carrito/agregar/1
S-->>H: 3 × <hx-partial>
H->>C: innerHTML "1"
H->>T: innerHTML "$ 8.990"
H->>A: afterbegin toast
Note over B: sin swap principal (hx-swap="none")
Cuándo NO fragmentar
Actualizar cinco zonas con cinco partials es correcto. Actualizar veinte suele ser señal de que convenía devolver una sección entera de la página y dejar que el morphing haga el trabajo fino:
<div id="panel" hx-get="/panel" hx-trigger="carritoActualizado from:body" hx-swap="innerMorph">
El morphing sólo toca lo que cambió, así que el costo de devolver el panel completo es bajo y el servidor se simplifica muchísimo.
Errores comunes
| Síntoma | Causa | Solución |
|---|---|---|
| El OOB no se aplica | No existe un elemento con ese id en el documento | Verificar el id del destino |
| El contenido del partial aparece dos veces | Hay contenido suelto además de los partials | Dejar sólo <hx-partial> |
El motor de plantillas borra <hx-partial> | Etiqueta desconocida | Usar <template hx type="partial"> |
hx-select no encuentra nada | El selector no existe en la respuesta | Inspeccionar la respuesta en la pestaña Red |
| El swap principal borra el target y luego el OOB falla | Orden de operaciones | Usar hx-swap="none" en el disparador |
Resumen
<hx-partial>es el mecanismo nativo de htmx 4: cada fragmento declara su target y su swap.- Si la respuesta son sólo partials, no hay swap principal.
<template hx type="partial">es la forma equivalente para motores que eliminan etiquetas desconocidas.hx-swap-oobsigue funcionando y ubica por coincidencia deid.hx-selectrecorta la respuesta desde el cliente;hx-select-oobreparte partes fuera de banda.- Cuando son demasiadas zonas, devolver una sección completa con
innerMorphes más simple.
Siguiente: Datos y formularios →.