Tutorial: Build a real-time comment thread
By the end of this tutorial you'll have a comment thread that:
- Lets logged-in users post a comment through a single form, with
full pending/error/success UX wired through
@actionanddj-form-pending. - Renders the existing comments with
dj-for, preserving the scroll position when the list grows. - Streams new comments to every connected reader within a few
hundred milliseconds of any user posting one — using PostgreSQL
LISTEN/NOTIFY(no polling, nosetInterval).
It's the smallest realistic example of djust's "one stack, one truth" pitch: the same Python view that handles the form submission also broadcasts to other connected users, with no separate API or job queue.
| You'll learn | Documented in |
|---|---|
| Server actions for form submissions | Server Actions |
| Pending UX during submit | Loading States |
| Rendering reactive lists | Lists (dj-for) |
| Live cross-user broadcasts | Database Notifications |
Prerequisites: The quickstart, a working Django project with djust, a configured database (PostgreSQL for the live-update step), and a Django user model. Familiarity with
@event_handlerhelps but isn't required.
What you're building
Comments on this post (3)
─────────────────────────
@alice · 2 min ago
This is the third comment.
@bob · 5 min ago
Glad someone built this in djust.
@carla · 10 min ago
First!
Add a comment
[ Type here… ]
[ Post ] ← flips to "Posting…" while in flight
When any user submits a comment, it appears in their own thread
and in every other open browser viewing the same thread, within
roughly the time it takes the database to publish a NOTIFY.
Step 1 — The model
Standard Django:
# myapp/models.py
from django.conf import settings
from django.db import models
class Post(models.Model):
title = models.CharField(max_length=200)
class Comment(models.Model):
post = models.ForeignKey(
Post, on_delete=models.CASCADE, related_name="comments"
)
author = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE
)
body = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at"]
Run python manage.py makemigrations && migrate and create a
single Post row for the demo.
Step 2 — The LiveView, with the post action
Create the LiveView. We track the list of comments as state and
expose a single @action for posting:
# myapp/views.py
from djust import LiveView, action, state
from .models import Comment, Post
class CommentThreadView(LiveView):
template_name = "comment_thread.html"
post_id = state(0)
comments = state(default_factory=list)
def mount(self, request, *, post_id: int, **kwargs):
if not request.user.is_authenticated:
self.redirect("/login/")
return
self.post_id = post_id
self.comments = self._fetch_comments()
def _fetch_comments(self):
qs = (
Comment.objects
.filter(post_id=self.post_id)
.select_related("author")
.order_by("-created_at")[:50]
)
return [
{
"id": c.id,
"author": c.author.username,
"body": c.body,
"created_at": c.created_at.isoformat(),
}
for c in qs
]
@action
def post_comment(self, body: str = "", **kwargs):
body = body.strip()
if not body:
raise ValueError("Comment cannot be empty.")
Comment.objects.create(
post_id=self.post_id,
author=self.request.user,
body=body,
)
# Re-fetch so we render the freshly-saved row alongside any
# comments that arrived from other readers in the meantime.
self.comments = self._fetch_comments()
return {"posted": True}
@action does three things compared to a bare @event_handler:
- Tracks
pending / error / resultstate across the handler's lifetime. - Auto-injects that state into the template under the action's name
— so the template can read
post_comment.pendingwithout any extra wiring. - Lets you pair with
dj-form-pendingfor declarative in-flight form UX (see Step 3).
Step 3 — The template
<!-- myapp/templates/comment_thread.html -->
<section>
<h2>Comments on this post ({{ comments|length }})</h2>
<hr />
<ul class="comments">
{% dj-for comment in comments %}
<li>
<strong>@{{ comment.author }}</strong>
<time datetime="{{ comment.created_at }}">{{ comment.created_at }}</time>
<p>{{ comment.body }}</p>
</li>
{% end-dj-for %}
</ul>
<hr />
<form dj-submit="post_comment">
<label>
Add a comment
<textarea name="body" rows="3" required dj-form-pending="disabled"></textarea>
</label>
<button type="submit" dj-form-pending="disabled">
<span dj-form-pending="hide">Post</span>
<span dj-form-pending="show" hidden>Posting…</span>
</button>
{% if post_comment.error %}
<p role="alert" class="error">{{ post_comment.error }}</p>
{% endif %}
</form>
</section>
What's doing what:
| Element | Behavior |
|---|---|
dj-for comment in comments | Per-row diffing — when self.comments grows by one, only that one <li> is patched into the DOM. The other rows don't re-render. |
<form dj-submit="post_comment"> | Submit fires the post_comment event, sending name="body" as a kwarg. |
dj-form-pending="disabled" on the textarea & button | Both get disabled while the submit is in flight. No prop drilling. |
dj-form-pending="hide" / ="show" on the spans | The "Post" label hides and "Posting…" shows during the round-trip. |
{% if post_comment.error %} | Reads the auto-injected action state. Rendered only when the handler raised — empty body, DB failure, etc. |
Step 4 — URL and try it
# myapp/urls.py
from django.urls import path
from .views import CommentThreadView
urlpatterns = [
path("posts/<int:post_id>/", CommentThreadView.as_view()),
]
Open /posts/1/ in two browser windows logged in as different
users. Post a comment in one. The comment shows up in that
window — but not yet in the other. That's what the next step
fixes.
Step 5 — Broadcast new comments to every reader
For other connected users to see the new comment, the LiveView
needs to be told that a write happened. The cleanest path is a
PostgreSQL LISTEN / NOTIFY channel — fire-and-forget on the
write side, push-based on the read side. djust ships
@notify_on_save (model-side) and self.listen(channel) /
handle_info(message) (view-side) to wire this up.
On the write side — decorate the model. Every save() and
delete() now fires NOTIFY <channel>, '<json>':
# myapp/models.py
from django.conf import settings
from django.db import models
from djust.db import notify_on_save
class Post(models.Model):
title = models.CharField(max_length=200)
@notify_on_save # default channel name: "<app_label>_<model_name>" → "myapp_comment"
class Comment(models.Model):
post = models.ForeignKey(
Post, on_delete=models.CASCADE, related_name="comments"
)
author = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE
)
body = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at"]
On the read side — subscribe in mount() and react in
handle_info():
class CommentThreadView(LiveView):
# ... as before ...
def mount(self, request, *, post_id: int, **kwargs):
if not request.user.is_authenticated:
self.redirect("/login/")
return
self.post_id = post_id
self.comments = self._fetch_comments()
self.listen("myapp_comment") # subscribe to the channel @notify_on_save emits
def handle_info(self, message):
"""Fires when any process NOTIFY's the channel we listen to."""
if message.get("type") != "db_notify":
return
# Cheap re-fetch — covers inserts, edits, deletes for any post.
# For very busy threads, scope by inspecting message["payload"].
self.comments = self._fetch_comments()
That's it — no signals.py, no apps.py wiring, no manual NOTIFY
SQL. The decorator is one line on the model, the subscription is
one line in mount(), the reaction is a 3-line handle_info().
Now reload both browser windows. Post a comment in window A — it appears in both windows within ~50–200 ms. No polling, no JavaScript timers, no separate API endpoint. The same Python view that handled the submit also reacted to the broadcast.
What just happened, end to end
Browser A Server Browser B
│ │ │
│ submit (post_comment)│ │
│ ─────────────────────►│ │
│ │ INSERT into comments │
│ │ ─────► postgres ──────│
│ │ post_save signal │
│ │ ───► NOTIFY │
│ │ ◄─── LISTEN dispatch │
│ │ on_comments_changed │
│ │ (every connected │
│ │ LiveView for │
│ │ this post) │
│ patch (new <li>) │ │
│ ◄─────────────────────│ │
│ │ patch (new <li>) │
│ │ ─────────────────────►│
Five concrete primitives carried the whole feature:
state(...)— declarescommentsas reactive; reassignment triggers diff + patch.@action— wrapspost_commentso the template can readpost_comment.pending/.errorautomatically.dj-form-pending— declarative in-flight form UX without a single line of client JS.dj-for— per-row diffing so adding one comment patches one<li>, not the whole list.@notify_on_save+self.listen()+handle_info()— server-push subscription to a Postgres channel, so any DB write becomes a UI update for every connected viewer. See Database Notifications for the full primitive reference.
Where to go next
- "Who's reading right now": add the Presence helper to show online viewers in the header — five lines of template, zero new infrastructure.
- Pagination: the demo loads 50 most-recent comments. For longer
threads, add cursor-based pagination triggered by an
IntersectionObserver-backed
dj-click="load_more". See Lists (dj-for). - Optimistic update: instead of
self.comments = self._fetch_comments(), push the new comment client-side immediately by appending toself.commentsbefore the DB write. TheLISTENecho then reconciles. - Per-thread channels: for very high write volume, override
@notify_on_save(channel=lambda inst: f"myapp_comment_{inst.post_id}")so each LiveView only receives notifications for the post it's rendering. See Database Notifications. - Soft-deletes & moderation: add an
@actionfordelete_comment(comment_id)with@permission_required("moderator"), same broadcast pattern.
The comment-thread shape — submit + render + cross-user broadcast — is the template for chat, live dashboards, collaborative editors, multiplayer game lobbies, and pretty much any multiplayer UI. Once these five primitives click, you have the full toolkit.