htmx y Django LiveView, lado a lado

Read in English

Si usas htmx y estás evaluando migrar a Django LiveView, este artículo es para ti. No es una comparación exhaustiva, sino un repaso de los casos más comunes y cómo se resuelven en cada tecnología. No lo leas como un tutorial de LiveView, ya que deja muchas características fuera.

Daré por sentado que conoces htmx y Django, y que sabes cómo crear vistas, templates y rutas. Si no es así, te recomiendo leer la documentación de cada uno antes de continuar.

Diferencias fundamentales

Antes de ver código, tenemos que ser claros: arquitectónicamente son muy diferentes. Ni siquiera usan el mismo protocolo de comunicación.

Característica htmx Django LiveView
Protocolo HTTP/AJAX WebSockets
Comunicación Peticiones individuales (GET, POST, etc.) Conexión persistente
Estado Stateless (sin estado) Stateful (con estado)
Actualizaciones en tiempo real Parcial con polling
Requisitos de infraestructura Mínimos (servidor HTTP) Moderados (Channels + Redis recomendado)
Latencia Mayor (cada petición HTTP) Menor (conexión persistente)
Soporte para broadcast No
Requiere API Sí, vistas o API REST No

En términos prácticos, htmx es más sencillo de configurar y usar para interacciones básicas. Sin embargo, cuando quieres realizar tareas complejas, la complejidad aumenta drásticamente. En cambio, Django LiveView requiere más configuración inicial, pero una vez que está en marcha, la curva es prácticamente plana. La lógica detrás de enviar un mensaje a todos los clientes conectados es trivial: se parece a abrir un modal.

Caso 1: Actualizar contenido con un clic

El ejemplo más básico. Un botón que actualiza un div.

htmx

<!-- Template -->
<div id="content">Contenido inicial</div>
<button hx-get="/update-content" hx-target="#content">
    Actualizar
</button>
# views.py
def update_content(request):
    return HttpResponse("<p>Contenido actualizado</p>")
# urls.py
path('update-content/', update_content),

Django LiveView

<!-- Template base -->
{% load static liveview %}
<!DOCTYPE html>
<html lang="es" data-room="{% liveview_room_uuid %}">
<head>
    <meta charset="UTF-8">
</head>
<body data-controller="page">
    <div id="content">{{ content }}</div>
    <button data-liveview-function="update_content"
            data-action="click->page#run">
        Actualizar
    </button>
    <script src="{% static 'liveview/liveview.min.js' %}" defer></script>
</body>
</html>

El atributo data-room genera un identificador único por carga y data-controller="page" activa el controlador que escucha los eventos. Ambos van en el HTML base; el resto de plantillas ya no los repiten.

# handlers.py
from liveview.decorators import liveview_handler
from liveview.connections import send

@liveview_handler("update_content")
def update_content(consumer, content):
    send(consumer, {
        "target": "#content",
        "html": "<p>Contenido actualizado</p>",
    })

Observa cómo htmx necesita una ruta específica, mientras LiveView lo maneja todo por WebSocket con un decorador. El handler no devuelve una respuesta HTTP: llama a send() para empujar el HTML al cliente, y puede llamarlo tantas veces como quiera.

Caso 2: Formulario con validación

Un formulario que valida en el servidor sin recargar la página.

htmx

<!-- Template -->
<form hx-post="/validate-form" hx-target="#errors">
    <input type="email" name="email">
    <div id="errors"></div>
    <button type="submit">Enviar</button>
</form>
# views.py
def validate_form(request):
    email = request.POST.get('email')
    if not email or '@' not in email:
        return HttpResponse('<p style="color:red;">Email inválido</p>')
    return HttpResponse('<p style="color:green;">Email válido</p>')
# urls.py
path('validate-form/', validate_form),

Django LiveView

<!-- Template -->
<form>
    <input type="email" name="email">
    <div id="errors">{{ error_message }}</div>
    <button type="submit"
            data-liveview-function="validate_form"
            data-action="click->page#run">Enviar</button>
</form>
# handlers.py
from liveview.decorators import liveview_handler
from liveview.connections import send

@liveview_handler("validate_form")
def validate_form(consumer, content):
    email = content.get("form", {}).get("email", "")

    if not email or '@' not in email:
        error_html = '<p style="color:red;">Email inválido</p>'
    else:
        error_html = '<p style="color:green;">Email válido</p>'

    send(consumer, {
        "target": "#errors",
        "html": error_html,
    })

LiveView serializa el formulario más cercano al botón y te lo entrega en content["form"], un diccionario con los campos por su name. No necesitas request.POST.

Caso 3: Búsqueda en tiempo real

Búsqueda que se actualiza mientras escribes.

htmx

<!-- Template -->
<input type="text"
       name="query"
       hx-get="/search"
       hx-trigger="keyup changed delay:500ms"
       hx-target="#results">
<div id="results"></div>
# views.py
def search(request):
    query = request.GET.get('query', '')
    results = Article.objects.filter(title__icontains=query)[:5]
    return render(request, 'search_results.html', {'results': results})
# urls.py
path('search/', search),

El atributo delay:500ms evita hacer peticiones en cada tecla pulsada.

Django LiveView

<!-- Template -->
<input type="text"
       name="query"
       data-liveview-function="search"
       data-action="input->page#run"
       data-liveview-debounce="500">
<div id="results">{% include 'search_results.html' %}</div>
# handlers.py
from django.template.loader import render_to_string
from liveview.decorators import liveview_handler
from liveview.connections import send
from .models import Article

@liveview_handler("search")
def search(consumer, content):
    query = content.get("form", {}).get("query", "")
    results = Article.objects.filter(title__icontains=query)[:5]

    html = render_to_string('search_results.html', {'results': results})

    send(consumer, {
        "target": "#results",
        "html": html,
    })

Aquí el disparador va sobre el propio input (input->page#run). El atributo data-liveview-debounce="500" cumple la misma función que delay:500ms en htmx.

Caso 4: Actualización automática (polling)

Contenido que se actualiza periódicamente.

htmx

<!-- Template -->
<div hx-get="/stats"
     hx-trigger="every 2s"
     id="stats">
    {{ stats }}
</div>
# views.py
def stats(request):
    active_users = get_active_users()
    return HttpResponse(f'<p>Usuarios activos: {active_users}</p>')
# urls.py
path('stats/', stats),

Django LiveView

Django LiveView no tiene polling automático del lado del cliente. La filosofía es diferente: el servidor hace broadcast cuando hay cambios. Si de verdad quieres un pulso periódico, lo lanzas como un proceso propio, no dentro del servidor web:

# management/commands/broadcast_stats.py
from time import sleep

from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.core.management.base import BaseCommand


class Command(BaseCommand):
    help = "Envía el número de usuarios activos a todos los clientes cada 2 segundos"

    def handle(self, *args, **options):
        channel_layer = get_channel_layer()
        while True:
            sleep(2)
            active_users = get_active_users()
            html = f"<p>Usuarios activos: {active_users}</p>"

            # 'broadcast' es el grupo al que LiveView une a todos los
            # clientes; 'broadcast_message' es el handler del consumer.
            async_to_sync(channel_layer.group_send)(
                "broadcast",
                {
                    "type": "broadcast_message",
                    "message": {"target": "#stats", "html": html},
                },
            )
<!-- Template -->
<div id="stats">{{ stats }}</div>

Lo arrancas como un proceso independiente (python manage.py broadcast_stats), gestionado por systemd o supervisor. No lo metas en un thread dentro del servidor ASGI: con varios workers tendrías una copia del bucle por worker, cada una emitiendo por su cuenta. Aun así, este enfoque es más potente que el polling: todos los clientes reciben la actualización a la vez, sin que cada uno pregunte por su cuenta.

Caso 5: Navegación SPA

Navegar sin recargar la página completa.

htmx

<!-- Template base -->
<nav hx-boost:inherited="true">
    <a href="/about">Sobre nosotros</a>
    <a href="/contact">Contacto</a>
</nav>

<div id="content">
    <!-- Contenido -->
</div>
# views.py
def about(request):
    return render(request, 'about.html')

def contact(request):
    return render(request, 'contact.html')
# urls.py
path('about/', about),
path('contact/', contact),

hx-boost convierte enlaces normales en peticiones AJAX. htmx intercepta el clic, hace una petición GET y reemplaza el <body> con el contenido de la respuesta. Fíjate en el sufijo :inherited: en htmx 4 es obligatorio para que el atributo del <nav> se aplique a los enlaces hijos. En htmx 2 bastaba con hx-boost="true", porque la herencia era automática.

Django LiveView

<!-- Template -->
<nav>
    <a href="#"
       data-liveview-function="load_about"
       data-action="click->page#run">
        Sobre nosotros
    </a>
    <a href="#"
       data-liveview-function="load_contact"
       data-action="click->page#run">
        Contacto
    </a>
</nav>

<div id="content">
    <!-- Contenido -->
</div>
# handlers.py
from django.template.loader import render_to_string
from liveview.decorators import liveview_handler
from liveview.connections import send

@liveview_handler("load_about")
def load_about(consumer, content):
    html = render_to_string('about.html')
    send(consumer, {
        "target": "#content",
        "html": html,
    })

@liveview_handler("load_contact")
def load_contact(consumer, content):
    html = render_to_string('contact.html')
    send(consumer, {
        "target": "#content",
        "html": html,
    })

Caso 6: Estado compartido entre usuarios

Múltiples usuarios viendo datos en tiempo real.

htmx

No hay soporte en el núcleo. Necesitas recurrir a Server-Sent Events (SSE) o WebSockets mediante extensiones (htmx 4 añade la extensión hx-live para esto), lo cual sale del modelo petición/respuesta de htmx.

Django LiveView

# handlers.py
from liveview.decorators import liveview_handler
from liveview.connections import send

@liveview_handler("add_message")
def add_message(consumer, content):
    message_text = content.get("form", {}).get("message", "")

    # broadcast=True difunde el mensaje a todos los clientes conectados
    send(
        consumer,
        {
            "target": "#messages",
            "html": f'<p>{message_text}</p>',
            "append": True,
        },
        broadcast=True,
    )
<!-- Template -->
<div id="messages">
    <!-- Los mensajes aparecen aquí -->
</div>
<form>
    <input type="text" name="message">
    <button type="submit"
            data-liveview-function="add_message"
            data-action="click->page#run">Enviar</button>
</form>

Todos los usuarios conectados reciben el mensaje instantáneamente.

Caso 7: Manejo de archivos

htmx

<form hx-post="/upload"
      hx-encoding="multipart/form-data"
      hx-target="#result">
    <input type="file" name="file">
    <button type="submit">Subir</button>
</form>
<div id="result"></div>
# views.py
def upload(request):
    if request.method == 'POST':
        uploaded_file = request.FILES['file']
        # Procesar archivo
        return HttpResponse(f'<p>Archivo {uploaded_file.name} subido</p>')
# urls.py
path('upload/', upload),

Django LiveView

<!-- Template -->
<form enctype="multipart/form-data">
    <input type="file" name="file">
    <button type="submit"
            data-liveview-function="upload_file"
            data-action="click->page#run">Subir</button>
</form>
<div id="result"></div>
# handlers.py
from liveview.decorators import liveview_handler
from liveview.connections import send

@liveview_handler("upload_file")
def upload_file(consumer, content):
    uploaded_file = content.get("form", {}).get("file")
    if uploaded_file:
        # Procesar archivo
        send(consumer, {
            "target": "#result",
            "html": f'<p>Archivo {uploaded_file} subido</p>',
        })

Aquí htmx tiene ventaja: sube el archivo por HTTP con multipart/form-data sin ningún truco. Sobre WebSocket, el archivo viaja dentro del mensaje JSON, así que para ficheros grandes la petición HTTP de htmx sigue siendo la opción más cómoda.

htmx ya tiene WebSockets

Los dos pueden hablar por WebSocket, pero no significa lo mismo cuando lo hacen.

En htmx, HTTP es el transporte por defecto y el WebSocket es una extensión que activas en un elemento concreto (en htmx 4, la extensión hx-live). Conectas ese trozo de la página a un socket y los mensajes que llegan traen HTML que htmx inserta. El resto de la aplicación sigue siendo HTTP. Además, el consumer del servidor lo escribes tú: defines el formato de los mensajes, las salas y la difusión.

En Django LiveView, el WebSocket es el transporte de toda la página. Cada interacción viaja por ahí, no solo los trozos en tiempo real. El framework gestiona la conexión, las salas, el broadcast y la reconexión; tú solo escribes handlers y llamas a send(). Por eso hacer broadcast a todos es una línea (send(..., broadcast=True)) en lugar de montar un consumer a mano.

Funcionalidad htmx + extensión WebSocket Django LiveView
Conexión y reconexión automática
Enviar eventos y actualizar el DOM con HTML
Estado por conexión en el servidor
Broadcast a todos los clientes 🟡 lo montas tú broadcast=True
Salas o grupos de clientes 🟡 lo montas tú
Historial y URL con snapshots y restauración de scroll ❌ (el historial de htmx es de su modo HTTP)
Debounce de eventos data-liveview-debounce
Disparadores por visibilidad (scroll o intersection) data-liveview-intersect
Mapas de atajos de teclado 🟡 filtros de tecla data-liveview-keyboard-map
Gestión de foco tras actualizar el DOM 🟡 hx-preserve data-liveview-focus

En conclusión, en htmx el WebSocket es un añadido para partes concretas de una aplicación que sigue siendo HTTP; en LiveView es la columna vertebral.

Apuntes finales

Puedes mantener SSR para algunas páginas, htmx en algunos componentes y usar LiveView en otras. Una tecnología no excluye a la otra. Ambas tecnologías son complementarias y buscan la máxima sencillez dentro de sus paradigmas.

Sin embargo, no son intercambiables: no puedes usar htmx para actualizar un componente gestionado por LiveView, y viceversa. Al final, eres tú quien tiene que decidir cuál se adapta mejor a cada caso. Pero, elijas la que elijas, que sea porque tienes experiencia en distintos paradigmas y no porque no conozcas la otra opción.

¡Happy Hacking!

Ayúdame a seguir escribiendo

Cada café me da un empujón para escribir el siguiente artículo.

Comentarios

Todavía no hay ningún comentario.

Sigue leyendo