Live Components with Django and htmx
I discovered django-components late last year and I quickly realized it was the missing piece in my Django + htmx workflow. It made my developer experience so much better, that I even started contributing to it.
django-components lets you build components that combine HTML, JS, and CSS in a single place. Plus, it now lets you use components as views. This feature allows you to keep all the logic for a part of your application in one place, giving you great locality of behavior.
A click-to-load component would look something like this:
from django.core.paginator import Paginator
from django_components import component
from src.app.models import Contact
@component.register("click_to_load")
class ClickToLoadTableComponent(component.Component):
template = """
{% for contact in page_obj %}
<tr>
<td>{{ contact.id }}</td>
<td>{{ contact.first_name }} {{ contact.last_name }}</td>
<td>{{ contact.email }}</td>
<td>{{ contact.status }}</td>
</tr>
{% if forloop.last and page_obj.has_next %}
<tr id="replaceMe">
<td colspan="4">
<button
class='primary'
hx-get="{% url 'contacts' page=page_obj.next_page_number %}"
hx-target="#replaceMe"
hx-swap="outerHTML">
Load more...
</button>
</td>
</tr>
{% endif %}
{% endfor %}
"""
def get_context_data(self, page_obj, **kwargs):
return {"page_obj": page_obj}
def get(self, request, page, **kwargs):
paginator = Paginator(Contact.objects.order_by("id"), 3)
page_obj = paginator.get_page(page)
context = {"page_obj": page_obj}
return self.render_to_response(context)You can use this component in any view using {% component 'click_to_load' page_obj=page_obj %} or render it outside of a view by adding it to urls.py:
Short and sweet, just like the best things in life.
Django Live Components
I thought it’d be fun to use the library for something it wasn’t designed for: streaming component changes through server-sent events (SSE).
It took me a few hours and several reads of Víðir’s tutorial to figure it out, but it worked. It’s a bit hacky but all the pieces were there. I just had to find a way to put them together.
The code is available here.
I had a simple idea: set up a Redis pub/sub channel for server notifications. When the client loads the page, it subscribes to this notification channel. Each time the server publishes a new notification, the system reads it from the channel. Then, it renders the HTML and sends it to the client using Server-Sent Events (SSE).
First, you need a notification component, with a streaming view that updates the client whenever a new notification occurs, and a way to subscribe to new notifications sent from the server.
Here’s what I came up with:
import asyncio
import json
from typing import AsyncGenerator
import redis.asyncio as redis
from django.http import StreamingHttpResponse
from django.utils.decorators import classonlymethod
from django_components import component
r = redis.from_url("redis://localhost")
def sse_message(event_id: int, event: str, data: str) -> str:
data = data.replace("\n", "")
return f"id: {event_id}\n" f"event: {event}\n" f"data: {data.strip()}\n\n"
class NotificationComponent(component.Component):
@classonlymethod
def as_live_view(cls, **initkwargs):
view = super().as_view(**initkwargs)
view._is_coroutine = asyncio.coroutines._is_coroutine
return view
template = """
<div style="color: {{color}};" role="alert">
<span style="font-weight: bold;">{{ title }}</span> {{ message }}
</div>
"""
async def streaming_response(self, *args, **kwargs) -> AsyncGenerator[str, None]:
async with r.pubsub() as pubsub:
await pubsub.subscribe("notifications_channel")
try:
while True:
message = await pubsub.get_message(
ignore_subscribe_messages=True, timeout=1
)
if message is not None:
notification_data = json.loads(message["data"].decode())
sse_message_rendered = sse_message(
notification_data["id"],
"notification",
self.render(
{
"title": notification_data["title"],
"message": notification_data["message"],
"color": notification_data["color"],
}
),
)
yield sse_message_rendered
await asyncio.sleep(0.1)
finally:
await r.aclose()
async def get(self, request, *args, **kwargs):
return StreamingHttpResponse(
streaming_content=self.streaming_response(),
content_type="text/event-stream",
)And you should include this in your urls.py:
Then, you need a simple HTML template to show these notifications. I used the htmx SSE extension to handle the SSE connection on the client. This was my template:
<!-- src/templates/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Django Live Components</title>
</head>
<body>
<div
hx-ext="sse"
sse-connect="{% url 'stream_notification' %}"
sse-swap="notification"
></div>
<script
src="https://unpkg.com/[email protected]"
integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC"
crossorigin="anonymous"
></script>
<script src="https://unpkg.com/htmx.org/dist/ext/sse.js"></script>
</body>
</html>Finally, you need a script to simulate these server notifications:
# random_notifications.py
import redis
import json
import random
import time
REDIS_HOST = "localhost"
REDIS_PORT = 6379
REDIS_CHANNEL = "notifications_channel"
r = redis.StrictRedis(host=REDIS_HOST, port=REDIS_PORT, decode_responses=True)
def create_random_notification():
"""Create a random notification message"""
return {
"id": random.randint(1, 1000),
"title": "Notification " + str(random.randint(1, 100)),
"message": "This is a random message " + str(random.randint(1, 100)),
"color": random.choice(["blue", "green", "red", "black", "gray", "purple"]),
"timestamp": time.ctime(),
}
def publish_notification():
"""Publish a random notification to the Redis channel"""
notification = create_random_notification()
r.publish(REDIS_CHANNEL, json.dumps(notification))
print(f"Published: {notification}")
if __name__ == "__main__":
try:
while True:
publish_notification()
time.sleep(3)
except KeyboardInterrupt:
print("Stopped notification publisher")You can run Redis on Docker to run this script. It’ll start adding notifications to the Redis channel, that you’ll see flash on the page.
This was fun. I ended up using a similar pattern in AItheneum.
Citation
@online{castillo2024,
author = {Castillo, Dylan},
title = {Live {Components} with {Django} and Htmx},
date = {2024-01-28},
url = {https://dylancastillo.co/til/live-components-with-django-and-htmx.html},
langid = {en}
}