De htmx a Django LiveView

Si usas htmx y estás evaluando Django LiveView, este artículo es para ti. Quiero enseñarte rápidamente las equivalencias de que sueles hacer con htmx y cómo lograrlo con Django LiveView. No es una comparación exhaustiva, sino una guía práctica para migrar o decidir cuál usar.

Diferencias fundamentales

Antes de ver código, tenemos que ser claros: arquitectónicamente son muy diferentes. Por no compartir, 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 No (polling necesario) Sí (push del servidor)
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
Rendimiento* Bueno (16.48ms) Excelente (9.35ms)
Requiere Vistas o API REST Django Channels

En términos prácticos, htmx es más sencillo de configurar y usar para interacciones básicas. Django LiveView es más potente para aplicaciones interactivas en tiempo real, cuando necesitas mantener estado en el servidor, hacer broadcast a múltiples clientes o crear aplicaciones tipo SPA.

Instalación y configuración

htmx

<!-- En tu template base -->
<script src="https://unpkg.com/htmx.org@2.0.8"></script>

Listo. Sin configuración de servidor.

Opcionalmente, puedes instalar django-htmx para facilitar el trabajo con htmx en Django. Proporciona middleware que detecta peticiones htmx y simplifica el manejo de headers:

pip install django-htmx
# settings.py
MIDDLEWARE = [
    # ... otros middleware
    'django_htmx.middleware.HtmxMiddleware',
]

Con esto puedes usar request.htmx en tus vistas para detectar peticiones htmx y acceder a headers específicos.

Django LiveView

Requiere Django Channels y un servidor ASGI como Daphne. Redis no es obligatorio, pero es altamente recomendable ya que si no perderás algunas funcionalidades de comunicación.

Instalación básica (sin Redis, solo para pruebas):

pip install django-liveview channels daphne

Instalación recomendada (con Redis, para desarrollo y producción):

pip install django-liveview channels channels-redis daphne redis
# settings.py
INSTALLED_APPS = [
    'daphne',  # Debe estar primero
    'django.contrib.staticfiles',
    # ... otras apps
    'liveview',
]

ASGI_APPLICATION = 'myproject.asgi.application'

# Opción 1: Sin Redis (solo pruebas locales)
CHANNEL_LAYERS = {
    'default': {
        'BACKEND': 'channels.layers.InMemoryChannelLayer',
    },
}

# Opción 2: Con Redis (recomendado)
CHANNEL_LAYERS = {
    'default': {
        'BACKEND': 'channels_redis.core.RedisChannelLayer',
        'CONFIG': {
            'hosts': [('127.0.0.1', 6379)],
        },
    },
}
# asgi.py
import os
from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack
from liveview import routing

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')

application = ProtocolTypeRouter({
    'http': get_asgi_application(),
    'websocket': AuthMiddlewareStack(
        URLRouter(routing.websocket_urlpatterns)
    ),
})
# urls.py
from django.urls import path, include

urlpatterns = [
    path('', include('liveview.urls')),
    # ... tus urls
]

Necesitas más paquetes y configuraciones. Pero como verás, la potencia que obtienes a cambio vale la pena.

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 -->
{% load liveview %}
<!DOCTYPE html>
<html data-liveview-room-uuid="{% liveview_room_uuid %}">
<head>
    {% liveview_headers %}
</head>
<body>
    <div id="content">{{ content }}</div>
    <button data-liveview-function="update_content"
            data-action="click->page#run">
        Actualizar
    </button>
</body>
</html>
# handlers.py
from liveview import liveview_handler

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

Observa cómo htmx necesita una ruta específica, mientras LiveView maneja todo via WebSocket con un decorador.

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 -->
{% load liveview %}
<form data-liveview-function="validate_form"
      data-action="submit->page#run">
    <input type="email" name="email" id="email-input">
    <div id="errors">{{ error_message }}</div>
    <button type="submit">Enviar</button>
</form>
# handlers.py
from liveview import liveview_handler

@liveview_handler("validate_form")
def validate_form(consumer, content):
    email = content.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>'

    return {
        "target": "#errors",
        "html": error_html,
    }

Con Django LiveView puedes acceder al estado del formulario directamente desde el objeto content sin tener que hacer 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 -->
{% load liveview %}
<input type="text"
       name="query"
       id="search-input"
       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 import liveview_handler
from .models import Article

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

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

    return {
        "target": "#results",
        "html": html,
    }

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. Pero puedes simularlo con un thread en el servidor:

# handlers.py
import threading
from time import sleep
from liveview import liveview_handler
from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync

def poll_stats():
    """Thread que actualiza stats cada 2 segundos"""
    channel_layer = get_channel_layer()
    while True:
        sleep(2)
        active_users = get_active_users()
        html = f'<p>Usuarios activos: {active_users}</p>'

        async_to_sync(channel_layer.group_send)(
            'broadcast',
            {
                'type': 'broadcast_message',
                'message': {
                    'target': '#stats',
                    'html': html,
                }
            }
        )

# Iniciar thread al arrancar la app
threading.Thread(target=poll_stats, daemon=True).start()
<!-- Template -->
<div id="stats">{{ stats }}</div>

Este enfoque es más potente porque todos los clientes conectados reciben la actualización simultáneamente, sin que cada uno haga polling individual.

Caso 5: Navegación SPA

Navegar sin recargar la página completa.

htmx

<!-- Template base -->
<nav hx-boost="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="true" 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.

Django LiveView

<!-- Template -->
{% load liveview %}
<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 import liveview_handler

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

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

Caso 6: Estado compartido entre usuarios

Múltiples usuarios viendo datos en tiempo real.

htmx

No hay soporte nativo. Necesitas implementar Server-Sent Events (SSE) o WebSockets manualmente, lo cual rompe el modelo de htmx.

Django LiveView

# handlers.py
from liveview import liveview_handler
from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync

@liveview_handler("add_message")
def add_message(consumer, content):
    message_text = content.get('message', '')
    channel_layer = get_channel_layer()

    # Broadcast a todos los usuarios conectados
    async_to_sync(channel_layer.group_send)(
        'chat_room',
        {
            'type': 'broadcast_message',
            'message': {
                'target': '#messages',
                'html': f'<p>{message_text}</p>',
            }
        }
    )
<!-- Template -->
<div id="messages">
    <!-- Mensajes aparecen aquí -->
</div>
<form data-liveview-function="add_message"
      data-action="submit->page#run">
    <input type="text" name="message">
    <button type="submit">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

{% load liveview %}
<form data-liveview-function="upload_file"
      data-action="submit->page#run"
      enctype="multipart/form-data">
    <input type="file" name="file" id="file-input">
    <button type="submit">Subir</button>
</form>
<div id="result"></div>
# handlers.py
from liveview import liveview_handler

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

Funcionalmente similar, pero sobre WebSocket.

Migración práctica

Si decides migrar de htmx a LiveView:

  1. Instala las dependencias básicas: django-liveview, channels, daphne (y opcionalmente channels-redis + Redis para producción)
  2. Configura ASGI: Modifica settings.py y asgi.py según la sección de instalación
  3. Configura channel layers: Usa InMemoryChannelLayer para pruebas o RedisChannelLayer para producción
  4. Convierte vistas a handlers: Cada vista que devuelve HTML parcial se convierte en un handler con @liveview_handler
  5. Actualiza templates: Reemplaza atributos hx-* por data-liveview-* y data-action. Además agrega los tags necesarios para LiveView

Puedes mantener SSR para algunas páginas, htmx en algunos componentes y usar LiveView en otras. Una tecnología no excluye a la otra.

No hay elección incorrecta. Ambas tecnologías son complementarias y buscan la máxima sencillez dentro de sus paradigmas. Elige la que mejor se adapte a tus necesidades y escala desde ahí. ¡Happy Hacking!

Este trabajo está bajo una licencia Attribution-NonCommercial-NoDerivatives 4.0 International.

¿Me invitas a un café?

Comentarios

Todavía no hay ningún comentario.

Escrito por Andros Fenollosa

febrero 3, 2026

8 min de lectura

Sigue leyendo

Visitantes en tiempo real

Estás solo: 🐱