UX: indicadores, sincronización y preservación
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(erahx-disableen 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:
| Estrategia | Comportamiento |
|---|---|
drop | Descarta la nueva petición si hay una en curso (por defecto) |
abort | Aborta la petición en curso y no lanza la nueva |
replace | Aborta la en curso y lanza la nueva |
queue first | Encola sólo la primera pendiente |
queue last | Encola sólo la última pendiente |
queue all | Encola 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
| Clase | Dónde se aplica | Cuándo |
|---|---|---|
htmx-request | Elemento disparador (y su hx-indicator) | Durante la petición |
htmx-indicator | Elemento marcado como indicador | Siempre; visible bajo htmx-request |
htmx-swapping | Target | Durante el retardo swap: |
htmx-added | Contenido nuevo | Durante 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
outerMorphpara no perder el foco. - Los reproductores y widgets tienen
hx-preserveo están enmorphIgnore. - Los paneles lentos cargan con
hx-trigger="load"sobre un skeleton. - Las eliminaciones tienen animación con
swap:<tiempo>.
Errores comunes
| Síntoma | Causa | Solución |
|---|---|---|
| El indicador no aparece | Falta la clase htmx-indicator | Añadirla al elemento |
| Doble envío al hacer doble clic | Nada bloquea el botón | hx-disable="this" |
| Resultados de búsqueda desordenados | Respuestas fuera de orden | hx-sync="this:replace" |
| El video se reinicia en cada swap | Se destruye y recrea | hx-preserve="true" |
hx-disabled-elt no funciona | Se renombró en htmx 4 | Usar hx-disable |
| La animación de salida se corta | Falta swap:<tiempo> | Añadir el modificador |
Resumen
htmx-requestyhtmx-indicatorson la base de los indicadores; con:inheritedse logra una barra de carga global.hx-disable(anteshx-disabled-elt) bloquea elementos durante la petición.hx-synccoordina peticiones concurrentes:replacepara búsquedas,droppara envíos.hx-preservemantiene elementos con estado intactos durante los swaps.hx-confirmpara acciones destructivas;htmx:confirmpara modales propios.- Las clases
htmx-swappingyhtmx-addedpermiten animar entradas y salidas con CSS puro.
Siguiente: Errores y códigos de estado →.