Partials, OOB y selección de respuesta

Por: Artiko
htmxhtmx4hx-partialoobhx-selectmulti-target

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["&lt;hx-partial&gt;<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:

AtributoQué hace
hx-targetSelector CSS del destino
idAtajo: equivale a hx-target="#<id>"
hx-swapEstrategia (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
DestinoSelector explícitoPor coincidencia de id
El fragmento necesita idNoSí, y debe coincidir
Se renderiza si htmx no estáNo (es un template)Sí, queda contenido suelto
EstrategiasTodasTodas
Anula el swap principalSí, si es lo únicoNo

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["&lt;hx-partial&gt;"]
    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 × &lt;hx-partial&gt;
    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íntomaCausaSolución
El OOB no se aplicaNo existe un elemento con ese id en el documentoVerificar el id del destino
El contenido del partial aparece dos vecesHay contenido suelto además de los partialsDejar sólo <hx-partial>
El motor de plantillas borra <hx-partial>Etiqueta desconocidaUsar <template hx type="partial">
hx-select no encuentra nadaEl selector no existe en la respuestaInspeccionar la respuesta en la pestaña Red
El swap principal borra el target y luego el OOB fallaOrden de operacionesUsar 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-oob sigue funcionando y ubica por coincidencia de id.
  • hx-select recorta la respuesta desde el cliente; hx-select-oob reparte partes fuera de banda.
  • Cuando son demasiadas zonas, devolver una sección completa con innerMorph es más simple.

Siguiente: Datos y formularios →.