UX: indicadores, sincronización y preservación

Por: Artiko
htmxhtmx4uxhx-indicatorhx-synchx-preserve

UX: indicadores, sincronización y preservación

Una aplicación htmx bien hecha se siente igual de fluida que una SPA. La diferencia está en cuatro detalles: mostrar que algo está pasando, impedir dobles envíos, coordinar peticiones y no destruir lo que el usuario está usando.

Indicadores de carga

htmx añade la clase htmx-request al elemento que dispara la petición mientras dura. La clase htmx-indicator tiene opacity: 0 por defecto y pasa a 1 dentro de un htmx-request:

<button hx-get="/datos">
  Cargar
  <img class="htmx-indicator" src="/spinner.svg" alt="Cargando…">
</button>

El spinner es invisible hasta que empieza la petición.

Indicador en otro elemento

<button hx-get="/datos" hx-indicator="#spinner">Cargar</button>
<img id="spinner" class="htmx-indicator" src="/spinner.svg" alt="">

Acepta selectores extendidos, así que hx-indicator="closest .card" funciona.

Una barra de carga global

El patrón más limpio, aprovechando la herencia:

<body hx-indicator:inherited="#barra">
  <div id="barra" class="htmx-indicator barra-carga"></div>

</body>
.barra-carga {
  position: fixed;
  top: 0;
  left: 0;
  height: 3px;
  width: 100%;
  background: linear-gradient(90deg, transparent, #ec4899, transparent);
  animation: deslizar 1s linear infinite;
}

@keyframes deslizar {
  from { transform: translateX(-100%); }
  to   { transform: translateX(100%); }
}

Indicadores con CSS propio

Si prefieres controlarlo todo tú, desactiva el CSS que inyecta htmx:

<meta name="htmx-config" content="includeIndicatorCSS:false">

Y estiliza directamente la clase htmx-request:

button.htmx-request {
  opacity: .6;
  cursor: progress;
}

button.htmx-request::after {
  content: "";
  display: inline-block;
  width: .8em;
  height: .8em;
  margin-left: .5em;
  border: 2px solid currentColor;
  border-top-color: transparent;
  border-radius: 50%;
  animation: girar .6s linear infinite;
}

@keyframes girar { to { transform: rotate(360deg); } }

Es más barato que un GIF y no requiere ningún elemento extra en el HTML.

hx-disable: evitar dobles envíos

Atención al renombre: en htmx 4, hx-disable deshabilita elementos durante la petición (era hx-disabled-elt en htmx 2).

<button hx-post="/pedido" hx-disable="this">Confirmar pedido</button>

Mientras la petición está en vuelo, el botón queda deshabilitado. Acepta selectores extendidos:

<form hx-post="/pedido" hx-disable="find button, find input">

</form>

Combinado con el indicador es la receta estándar contra el doble clic:

<button hx-post="/pedido" hx-disable="this" hx-indicator="closest form">
  Confirmar pedido
</button>

El atributo que impide que htmx procese un subárbol es hx-ignore (era hx-disable en htmx 2). Ver el capítulo 18.

hx-sync: coordinar peticiones

Cuando varios elementos pueden disparar peticiones que se pisan, hx-sync define la política.

<form hx-post="/guardar" hx-sync="this:replace">
  <input id="titulo"
         name="titulo"
         hx-post="/validar"
         hx-trigger="change"
         hx-sync="closest form">
  <button type="submit">Guardar</button>
</form>

El input valida en cada cambio, pero si el usuario envía el formulario, la validación en vuelo se descarta: manda el envío.

Estrategias:

EstrategiaComportamiento
dropDescarta la nueva petición si hay una en curso (por defecto)
abortAborta la petición en curso y no lanza la nueva
replaceAborta la en curso y lanza la nueva
queue firstEncola sólo la primera pendiente
queue lastEncola sólo la última pendiente
queue allEncola todas
flowchart TD
    A{¿Qué quieres<br/>cuando llega otra?} --> B[Ignorar la nueva]
    A --> C[Cancelar la vieja]
    A --> D[Procesarlas todas]
    B --> B1["hx-sync='this:drop'"]
    C --> C1["hx-sync='this:replace'"]
    D --> D1["hx-sync='this:queue all'"]

Caso típico: una búsqueda en vivo donde sólo importa el resultado más reciente:

<input hx-get="/buscar"
       hx-trigger="input changed delay:300ms"
       hx-sync="this:replace"
       hx-target="#resultados">

Sin hx-sync, dos respuestas lentas podrían llegar desordenadas y dejar en pantalla el resultado de una búsqueda anterior.

Abortar manualmente

<button onclick="htmx.trigger('#boton-peticion', 'htmx:abort')">Cancelar</button>

hx-preserve: no tocar este elemento

Durante un swap, los elementos con hx-preserve se mantienen tal cual, con todo su estado:

<video id="video-tutorial" hx-preserve="true">
  <source src="/tutorial.mp4">
</video>

Aunque el contenedor se reemplace entero, el video sigue reproduciéndose en el mismo segundo.

Casos donde es imprescindible:

  • Reproductores de audio y video
  • Mapas (Leaflet, MapLibre)
  • Editores de texto enriquecido
  • Iframes de terceros
  • Cualquier widget con estado interno costoso de reconstruir

El elemento debe tener un id estable y aparecer también en el HTML nuevo.

Para proteger categorías enteras de elementos, la configuración de morphing es más adecuada:

<meta name="htmx-config" content='morphIgnore:"[data-widget]"'>

hx-confirm: confirmar antes de actuar

<button hx-delete="/cuenta" hx-confirm="Esta acción es irreversible. ¿Continuar?">
  Eliminar cuenta
</button>

Con herencia, para todo un subárbol de acciones destructivas:

<div hx-confirm:inherited="¿Seguro que quieres continuar?">
  <button hx-delete="/a">Eliminar A</button>
  <button hx-delete="/b">Eliminar B</button>
</div>

Usa el confirm() nativo. Para un modal propio, intercepta htmx:confirm como se vio en el capítulo 10, o usa la extensión hx-prompt si necesitas pedir un valor además de confirmar.

Las clases CSS del ciclo

ClaseDónde se aplicaCuándo
htmx-requestElemento disparador (y su hx-indicator)Durante la petición
htmx-indicatorElemento marcado como indicadorSiempre; visible bajo htmx-request
htmx-swappingTargetDurante el retardo swap:
htmx-addedContenido nuevoDurante la fase de settle

Animación completa de una fila que se elimina y otra que entra:

tr.htmx-swapping {
  opacity: 0;
  transform: translateX(-20px);
  transition: all 250ms ease-out;
}

tr.htmx-added {
  opacity: 0;
}

tr {
  opacity: 1;
  transition: opacity 250ms ease-in;
}
<tr hx-delete="/items/9" hx-target="closest tr" hx-swap="outerHTML swap:250ms">

Skeletons y contenido diferido

Combina hx-trigger="load" con un placeholder real en el HTML:

<div hx-get="/panel/estadisticas" hx-trigger="load" hx-swap="innerHTML">
  <div class="skeleton" style="height: 8rem"></div>
</div>
.skeleton {
  background: linear-gradient(90deg, #eee 25%, #f5f5f5 50%, #eee 75%);
  background-size: 200% 100%;
  animation: brillo 1.2s infinite;
  border-radius: .5rem;
}

@keyframes brillo {
  to { background-position: -200% 0; }
}

La página se pinta al instante con la estructura, y cada panel se llena cuando su consulta termina.

Feedback optimista

Para acciones donde la respuesta del servidor es previsible (un “me gusta”, marcar como leído), la extensión hx-optimistic muestra el resultado antes de que llegue:

<script src="/js/ext/hx-optimistic.js"></script>
<meta name="htmx-config" content='extensions:"hx-optimistic"'>

<template id="tpl-enviando">
  <li class="mensaje">Enviando…</li>
</template>

<form hx-post="/mensajes"
      hx-target="#mensajes"
      hx-swap="beforeend"
      hx-optimistic="#tpl-enviando">
  <input name="texto">
  <button type="submit">Enviar</button>
</form>
.hx-optimistic {
  opacity: .6;
  font-style: italic;
}

Se cubre completo en el capítulo 15.

Checklist de UX

  • Toda acción que tarde más de 100 ms muestra un indicador.
  • Los botones que envían datos usan hx-disable="this".
  • Las búsquedas en vivo usan delay + hx-sync="this:replace".
  • Las acciones destructivas tienen hx-confirm.
  • Los formularios que se revalidan usan outerMorph para no perder el foco.
  • Los reproductores y widgets tienen hx-preserve o están en morphIgnore.
  • Los paneles lentos cargan con hx-trigger="load" sobre un skeleton.
  • Las eliminaciones tienen animación con swap:<tiempo>.

Errores comunes

SíntomaCausaSolución
El indicador no apareceFalta la clase htmx-indicatorAñadirla al elemento
Doble envío al hacer doble clicNada bloquea el botónhx-disable="this"
Resultados de búsqueda desordenadosRespuestas fuera de ordenhx-sync="this:replace"
El video se reinicia en cada swapSe destruye y recreahx-preserve="true"
hx-disabled-elt no funcionaSe renombró en htmx 4Usar hx-disable
La animación de salida se cortaFalta swap:<tiempo>Añadir el modificador

Resumen

  • htmx-request y htmx-indicator son la base de los indicadores; con :inherited se logra una barra de carga global.
  • hx-disable (antes hx-disabled-elt) bloquea elementos durante la petición.
  • hx-sync coordina peticiones concurrentes: replace para búsquedas, drop para envíos.
  • hx-preserve mantiene elementos con estado intactos durante los swaps.
  • hx-confirm para acciones destructivas; htmx:confirm para modales propios.
  • Las clases htmx-swapping y htmx-added permiten animar entradas y salidas con CSS puro.

Siguiente: Errores y códigos de estado →.