Swaps y morphing

Por: Artiko
htmxhtmx4hx-swapmorphingidiomorphview-transitions

Swaps y morphing

hx-swap responde a cómo entra la respuesta en el target. Por defecto es innerHTML.

<div hx-get="/datos" hx-swap="outerHTML"></div>

Las estrategias

ValorQué hace
innerHTMLReemplaza el contenido del target (por defecto)
outerHTMLReemplaza el target completo, incluido él mismo
textContentInserta como texto plano, sin parsear HTML
beforebegin / beforeInserta antes del target, como hermano
afterbegin / prependInserta como primer hijo
beforeend / appendInserta como último hijo
afterend / afterInserta después del target, como hermano
innerMorphMorphing del contenido del target
outerMorphMorphing del target completo
outerSyncSincroniza el target con la respuesta preservando identidad
upsertActualiza si existe, inserta si no
deleteElimina el target, ignorando la respuesta
noneNo hace swap principal (para OOB o sólo headers)

Los alias (before, prepend, append, after) son nuevos y más legibles que las cuatro posiciones de insertAdjacentHTML.

Dónde inserta cada posición

flowchart TD
    P["&lt;div id='padre'&gt;"] --> BB["beforebegin — antes del target"]
    P --> T["&lt;div id='target'&gt;"]
    T --> AB["afterbegin — primer hijo"]
    T --> H["contenido existente"]
    T --> BE["beforeend — último hijo"]
    P --> AE["afterend — después del target"]

Ejemplos típicos:

<!-- Añadir un mensaje al final de un chat -->
<form hx-post="/mensajes" hx-target="#chat" hx-swap="beforeend">…</form>

<!-- Añadir una notificación arriba del todo -->
<button hx-get="/aviso" hx-target="#avisos" hx-swap="afterbegin">Avisar</button>

<!-- Eliminar una fila -->
<button hx-delete="/items/3" hx-target="closest tr" hx-swap="delete">Borrar</button>

<!-- Sólo interesa el header de la respuesta -->
<button hx-post="/log" hx-swap="none">Registrar</button>

textContent: el swap seguro

Nuevo en htmx 4. Inserta la respuesta sin parsearla como HTML:

<span hx-get="/nombre-usuario" hx-swap="textContent"></span>

Si el servidor devuelve <script>alert(1)</script>, aparece como texto literal. Es la opción correcta cuando el contenido viene de datos que escribió un usuario y no necesitas marcado.

Morphing: innerMorph y outerMorph

Esta es la incorporación estrella de htmx 4. En htmx 2, el morphing vivía en la extensión Idiomorph; ahora está en el core.

Un swap normal destruye y recrea el DOM. Eso significa perder:

  • el foco del teclado (el usuario estaba escribiendo en un input)
  • la posición del scroll dentro de un contenedor
  • el estado de reproducción de un <video> o <audio>
  • el estado interno de cualquier widget JS
  • la animación CSS en curso

El morphing compara el árbol viejo con el nuevo y aplica sólo las diferencias.

flowchart LR
    subgraph N["innerHTML"]
        A1[DOM viejo] --> A2[destruir todo] --> A3[crear todo de nuevo]
        A3 --> A4["❌ foco, scroll y estado perdidos"]
    end
    subgraph M["innerMorph"]
        B1[DOM viejo] --> B2[diff contra el nuevo] --> B3[tocar sólo lo distinto]
        B3 --> B4["✅ foco, scroll y estado intactos"]
    end

Cuándo usarlo:

<!-- Un formulario que se revalida en el servidor mientras el usuario escribe -->
<form hx-post="/validar"
      hx-trigger="input delay:300ms"
      hx-target="this"
      hx-swap="outerMorph">
  <input name="email" placeholder="Email">
  <span class="error"></span>
</form>

Con outerHTML, el usuario perdería el cursor en cada validación. Con outerMorph, el input sigue enfocado y con el cursor donde estaba.

Controlar el morphing

Tres opciones de configuración global:

ConfigQué hace
morphIgnoreSelector de elementos que el morphing no toca en absoluto
morphSkipSelector de elementos cuyo nodo no se actualiza
morphSkipChildrenSelector de elementos cuyos hijos no se recorren
morphScanLimitLímite de nodos que el algoritmo escanea buscando coincidencias
<meta name="htmx-config" content='morphIgnore:"[data-widget-js]"'>

Así proteges los contenedores manejados por bibliotecas externas (un mapa, un gráfico, un editor de texto enriquecido) de que el morphing les reordene los nodos internos.

Para casos puntuales existe hx-preserve, que se ve en el capítulo 11.

La clave del morphing: los id

El algoritmo empareja nodos por id cuando existen. Sin ids, empareja por posición y tipo de etiqueta, lo que funciona pero es menos preciso. En listas que se reordenan, pon ids estables:

<ul id="tareas">
  <li id="tarea-14">Comprar café</li>
  <li id="tarea-9">Pagar la luz</li>
</ul>

Con esos ids, mover un elemento a otra posición no lo destruye: lo mueve.

upsert y outerSync

Dos estrategias menos conocidas pero muy prácticas.

upsert: si el elemento con ese id ya existe en el documento, lo actualiza; si no, lo inserta. Evita tener que decidir en el servidor si la acción es “crear” o “actualizar”:

<div id="lista" hx-get="/items" hx-swap="upsert"></div>

outerSync: reemplaza el target sincronizando la identidad de los nodos, un punto intermedio entre outerHTML y outerMorph.

Modificadores

Se escriben en HCON después de la estrategia:

<div hx-get="/datos" hx-swap="innerHTML swap:200ms settle:100ms scroll:top"></div>
ModificadorValoresPara qué
swaptiempoRetardo antes de insertar (deja correr una animación de salida)
settletiempoDuración de la fase de settle (animación de entrada)
transitiontrue/falseUsa la View Transitions API
scrolltop/bottomSalta el scroll del target arriba o abajo
showtop/bottomDesplaza para que el target quede visible
scrollTargetselectorAplica scroll a otro elemento
showTargetselectorAplica show a otro elemento
focusScrolltrue/falseDesplaza al elemento que recibe el foco
ignoreTitletrue/falseNo actualiza el <title> con el de la respuesta
striptrue/falseElimina el elemento contenedor de la respuesta
swapEmptytrue/falseHace swap aunque la respuesta esté vacía
targetselectorSobrescribe el target desde el propio hx-swap

Ejemplos:

<!-- Chat: añadir al final y bajar el scroll -->
<div hx-get="/mensajes" hx-swap="beforeend scroll:bottom"></div>

<!-- Animación de salida de 300 ms antes del reemplazo -->
<div hx-delete="/item/2" hx-swap="outerHTML swap:300ms"></div>

<!-- Que el contenedor con la lista, no el target, haga scroll -->
<div hx-get="/pagina/2" hx-swap="innerHTML scroll:top scrollTarget:#panel"></div>

<!-- No pisar el título de la página -->
<div hx-get="/fragmento" hx-swap="innerHTML ignoreTitle:true"></div>

Las fases del swap y las animaciones

Todo swap pasa por tres fases, y htmx añade clases CSS en cada una:

sequenceDiagram
    participant H as htmx
    participant D as DOM
    H->>D: htmx:before:swap
    Note over D: espera `swap:<tiempo>`<br/>(clase htmx-swapping en el target)
    H->>D: inserta el HTML nuevo
    H->>D: htmx:before:settle
    Note over D: clase htmx-added en lo nuevo<br/>durante `settle:<tiempo>`
    H->>D: htmx:after:settle

Eso permite animar sin JavaScript:

/* Salida */
.htmx-swapping {
  opacity: 0;
  transition: opacity 300ms ease-out;
}

/* Entrada */
.htmx-added {
  opacity: 0;
}
.fila {
  opacity: 1;
  transition: opacity 300ms ease-in;
}
<tr class="fila" hx-delete="/items/5" hx-target="closest tr" hx-swap="outerHTML swap:300ms">

El swap:300ms le da tiempo a la transición de opacidad antes de que el nodo desaparezca.

View Transitions

La View Transitions API del navegador anima automáticamente entre dos estados del DOM. htmx la integra:

<!-- Por petición -->
<div hx-get="/detalle" hx-swap="innerHTML transition:true"></div>
<!-- O globalmente -->
<meta name="htmx-config" content="transitions:true">

Para animar un elemento concreto entre estados, dale un view-transition-name en CSS:

.tarjeta-destacada {
  view-transition-name: destacada;
}

::view-transition-old(destacada),
::view-transition-new(destacada) {
  animation-duration: 300ms;
}

htmx 4 encola las transiciones: si llegan varias respuestas seguidas, se ejecutan en orden en lugar de cancelarse unas a otras, que era el defecto visual más molesto de htmx 2.

Los navegadores sin soporte simplemente hacen el swap sin animar: no hay que hacer nada extra.

Reasignar el swap desde el servidor

return new Response(fragmento, {
  headers: {
    "Content-Type": "text/html",
    "HX-Reswap": "outerHTML transition:true",
  },
});

Acepta la misma sintaxis que el atributo, modificadores incluidos.

Elegir la estrategia

flowchart TD
    A{¿Qué necesitas?} --> B[Refrescar contenido interno]
    A --> C[Reemplazar el elemento entero]
    A --> D[Añadir a una lista]
    A --> E[Quitar un elemento]
    A --> F[Refrescar sin perder foco/scroll]
    A --> G[Mostrar texto de usuario]
    B --> B1[innerHTML]
    C --> C1[outerHTML]
    D --> D1["beforeend / afterbegin"]
    E --> E1[delete]
    F --> F1["innerMorph / outerMorph"]
    G --> G1[textContent]

Errores comunes

SíntomaCausaSolución
El elemento se anida dentro de sí mismoEl servidor devuelve el contenedor y usas innerHTMLUsa outerHTML o devuelve sólo el interior
Se pierde el cursor al validar mientras se escribeouterHTML destruye el inputUsa outerMorph
La animación de salida no se veFalta el modificador swap:<tiempo>hx-swap="outerHTML swap:300ms"
El <title> cambia soloLa respuesta trae un <title>ignoreTitle:true
No pasa nada con respuesta vacíaComportamiento por defectoswapEmpty:true o devolver 204 a propósito
El widget JS se rompe al hacer morphEl morphing reordena sus nodosmorphIgnore o hx-preserve

Resumen

  • El swap por defecto es innerHTML; outerHTML reemplaza el elemento completo.
  • Los alias before, prepend, append, after son más legibles que las posiciones adjacent.
  • innerMorph y outerMorph traen Idiomorph al core: preservan foco, scroll y estado JS.
  • Los id estables son la clave para que el morphing empareje bien los nodos.
  • textContent inserta sin parsear: la opción segura para contenido de usuario.
  • Los modificadores controlan tiempos, scroll, transiciones y título.
  • Las clases htmx-swapping y htmx-added permiten animar con CSS puro.
  • transition:true activa las View Transitions, ahora encoladas y sin cancelaciones.

Siguiente: Herencia explícita y HCON →.