Herencia explícita y HCON

Por: Artiko
htmxhtmx4herenciainheritedhconconfiguracion

Herencia explícita y HCON

Este es el cambio conceptual más grande de htmx 4. Si vienes de htmx 2, es lo primero que te va a romper el código; si empiezas de cero, es lo que hace que htmx 4 sea predecible.

El problema de la herencia implícita

htmx 2 copió el modelo de CSS: un atributo declarado en un ancestro afectaba a todos sus descendientes.

<!-- htmx 2 -->
<div hx-target="#resultado">
  <button hx-get="/a">A</button>   <!-- apunta a #resultado -->
  <button hx-get="/b">B</button>   <!-- apunta a #resultado -->
</div>

Cómodo en ejemplos pequeños. En una aplicación real, el problema es que no puedes saber qué hace un elemento leyéndolo: para entender un <button hx-get="/a"> había que subir por todo el árbol buscando ancestros con atributos hx-*. Y como el HTML se compone desde plantillas parciales, un fragmento reutilizable heredaba cosas distintas según dónde lo incrustaras.

htmx 2 intentó parchearlo con hx-disinherit y hx-inherit, atributos que existían sólo para apagar una herencia que nadie había pedido explícitamente.

La solución de htmx 4: :inherited

En htmx 4 nada se hereda salvo que lo pidas, con el modificador :inherited:

<!-- htmx 4 -->
<div hx-target:inherited="#resultado">
  <button hx-get="/a">A</button>   <!-- apunta a #resultado -->
  <button hx-get="/b">B</button>   <!-- apunta a #resultado -->
  <button hx-get="/c" hx-target="#otro">C</button>  <!-- gana el local -->
</div>

Reglas:

  1. Sin :inherited, el atributo aplica sólo al elemento donde está escrito.
  2. Con :inherited, aplica al elemento y a todos sus descendientes.
  3. Un valor local en el descendiente gana sobre el heredado.
  4. El ancestro más cercano gana sobre el más lejano.
flowchart TD
    A["&lt;body hx-target:inherited='#main'&gt;"] --> B["&lt;section hx-target:inherited='#panel'&gt;"]
    B --> C["&lt;button hx-get='/x'&gt;<br/>→ #panel (ancestro más cercano)"]
    B --> D["&lt;button hx-get='/y' hx-target='#modal'&gt;<br/>→ #modal (local gana)"]
    A --> E["&lt;button hx-get='/z'&gt;<br/>→ #main"]

Qué atributos se pueden heredar

Cualquiera que tenga sentido aplicar a un subárbol:

<div hx-confirm:inherited="¿Estás seguro?">…</div>
<div hx-target:inherited="#panel">…</div>
<div hx-swap:inherited="outerHTML">…</div>
<div hx-boost:inherited="true">…</div>
<div hx-indicator:inherited="#spinner">…</div>
<div hx-headers:inherited='{"X-Tenant":"acme"}'>…</div>
<div hx-include:inherited="#filtros">…</div>
<div hx-sync:inherited="closest form">…</div>

Los que no se heredan son los que definen la petición en sí: hx-get, hx-post, hx-put, hx-patch, hx-delete. Sería absurdo que todos los hijos de un div hicieran la misma petición.

:append: fusionar en vez de sustituir

A veces no quieres reemplazar el valor heredado sino sumarle el tuyo. Para eso está :append:

<div hx-headers:inherited='{"X-Tenant": "acme"}'>
  <button hx-get="/api"
          hx-headers:append='{"X-Request-ID": "123"}'>
    Enviar
  </button>
</div>

Esa petición manda los dos headers. Sin :append, el valor local habría reemplazado por completo al heredado y X-Tenant se habría perdido.

Se puede combinar con :inherited para que la fusión también se propague:

<div hx-include:inherited="#campos-globales">
  <form hx-include:inherited:append=".campos-extra">
    <!-- Los descendientes incluyen ambos selectores -->
  </form>
</div>

:append tiene sentido en atributos acumulativos: hx-headers, hx-vals, hx-include, hx-trigger. En un hx-target no lo tiene: un destino es uno solo.

El patrón de layout

La forma idiomática de trabajar en htmx 4 es declarar la herencia en el layout, una sola vez:

<body hx-boost:inherited="true"
      hx-target:inherited="#contenido"
      hx-indicator:inherited="#barra-carga"
      hx-headers:inherited='{"X-CSRF-Token": "abc123"}'>

  <div id="barra-carga" class="htmx-indicator"></div>

  <nav>
    <a href="/inicio">Inicio</a>
    <a href="/tareas">Tareas</a>
  </nav>

  <main id="contenido">…</main>
</body>

Con eso: toda la navegación es AJAX, todo cae en #contenido, todo muestra la misma barra de carga y toda petición lleva el token CSRF. Los fragmentos que el servidor devuelva heredan esa configuración al insertarse, porque la herencia se resuelve por posición en el DOM, no por el momento en que llegó el HTML.

hx-config: configuración por subárbol

Además de la configuración global, htmx 4 permite configurar un subárbol concreto:

<div hx-config="timeout:5000 credentials:'include'">
  <button hx-get="/api-lenta">Consultar</button>
</div>

Y por elemento:

<button hx-post="/subida-grande" hx-config="timeout:120000">Subir</button>

La precedencia es la esperable: elemento > subárbol > <meta> global > valores por defecto.

HCON en detalle

HCON (htmx Configuration Object Notation) es la sintaxis de pares clave:valor que aparece en todo htmx 4. Existe porque escribir JSON dentro de un atributo HTML es incómodo: comillas escapadas, llaves y comas por todos lados.

<!-- HCON -->
<div hx-swap="innerHTML swap:200ms settle:100ms scroll:top">

<!-- El mismo valor en JSON sería impracticable en un atributo -->

Reglas de sintaxis

<!-- Pares separados por espacio o coma -->
content="defaultSwap:outerHTML transitions:true"
content="defaultSwap:outerHTML, transitions:true"

<!-- Clave sola = true -->
hx-config="validate"          <!-- {validate: true} -->

<!-- Booleanos y números sin comillas -->
hx-config="validate:false timeout:5000"

<!-- Strings con comillas -->
hx-config='credentials:"include"'

<!-- Anidamiento con punto -->
content="ws.reconnect:true ws.reconnectDelay:1s"
<!-- {ws: {reconnect: true, reconnectDelay: "1s"}} -->

<!-- Tiempos con unidad -->
hx-trigger="click delay:500ms throttle:1s"

JSON sigue funcionando

En cualquier atributo que acepte HCON puedes escribir JSON:

<button hx-vals='{"id": 42, "tags": ["a", "b"]}'>Enviar</button>

Usa JSON cuando necesites arrays u objetos anidados; usa HCON para todo lo demás.

Dónde aparece HCON

LugarEjemplo
hx-swapinnerHTML swap:200ms settle:100ms
hx-triggerclick delay:500ms throttle:1s from:body
hx-configtimeout:5000 credentials:"include"
hx-valstoken:"abc" reintentos:3
hx-headersX-Tenant:"acme"
hx-statusswap:innerHTML target:#errores
<meta name="htmx-config">defaultSwap:outerHTML transitions:true
Header HX-Locationpath:"/tareas" target:"#main"

Volver al comportamiento de htmx 2

Si estás migrando y no quieres reescribir todo de golpe:

<script>
  htmx.config.implicitInheritance = true;
</script>
<script src="/js/htmx.min.js"></script>

O cargando la extensión de compatibilidad:

<script src="/js/htmx.min.js"></script>
<script src="/js/ext/htmx-2-compat.js"></script>

Es una muleta de transición, no un destino: con la herencia implícita activada pierdes justamente la propiedad que hace mantenible el código en htmx 4. Existe además el evento htmx:after:implicitInheritance, útil para auditar dónde se está aplicando herencia implícita mientras migras.

Errores comunes

SíntomaCausaSolución
Los botones apuntan a sí mismos tras migrarEl hx-target del contenedor ya no se heredaAñadir :inherited
El header global desaparece en un botón concretoEl hx-headers local reemplazó al heredadoUsar hx-headers:append
hx-get en un div no afecta a sus hijosLos verbos no son heredablesEscribirlo en cada elemento
El fragmento nuevo no hereda nadaEstá fuera del subárbol con :inheritedRevisar dónde se inserta
HCON no parsea un valorFalta comillas en un string con caracteres especialesclave:"valor con espacios"

Resumen

  • En htmx 4 no hay herencia salvo que la pidas con :inherited.
  • El valor local siempre gana sobre el heredado; el ancestro más cercano gana sobre el lejano.
  • :append fusiona el valor local con el heredado (headers, vals, include, trigger).
  • Los verbos de petición nunca se heredan.
  • El patrón idiomático es declarar la herencia una vez en el layout.
  • hx-config configura un subárbol; la precedencia es elemento > subárbol > meta > defaults.
  • HCON es la sintaxis clave:valor compacta; JSON sigue disponible para estructuras complejas.
  • implicitInheritance y htmx-2-compat son muletas de migración, no soluciones permanentes.

Siguiente: Partials, OOB y selección →.