htmx y Django LiveView, lado a lado
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 | Sí |
| 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 | Sí |
| 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!
- Diferencias fundamentales
- Caso 1: Actualizar contenido con un clic
- htmx
- Django LiveView
- Caso 2: Formulario con validación
- htmx
- Django LiveView
- Caso 3: Búsqueda en tiempo real
- htmx
- Django LiveView
- Caso 4: Actualización automática (polling)
- htmx
- Django LiveView
- Caso 5: Navegación SPA
- htmx
- Django LiveView
- Caso 6: Estado compartido entre usuarios
- htmx
- Django LiveView
- Caso 7: Manejo de archivos
- htmx
- Django LiveView
- htmx ya tiene WebSockets
- Apuntes finales
Este trabajo está bajo una licencia Attribution-NonCommercial-NoDerivatives 4.0 International.
Ayúdame a seguir escribiendo
Cada café me da un empujón para escribir el siguiente artículo.
¡Claro, te invito!
Comentarios
Todavía no hay ningún comentario.