Skip to content
djust/docs
Appearance
Mode
djust.org →
Browse documentation

5 min read

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 @action and dj-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, no setInterval).

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 learnDocumented in
Server actions for form submissionsServer Actions
Pending UX during submitLoading States
Rendering reactive listsLists (dj-for)
Live cross-user broadcastsDatabase 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_handler helps 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:

  1. Tracks pending / error / result state across the handler's lifetime.
  2. Auto-injects that state into the template under the action's name — so the template can read post_comment.pending without any extra wiring.
  3. Lets you pair with dj-form-pending for 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&hellip;</span>
    </button>

    {% if post_comment.error %}
      <p role="alert" class="error">{{ post_comment.error }}</p>
    {% endif %}
  </form>
</section>

What's doing what:

ElementBehavior
dj-for comment in commentsPer-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 & buttonBoth get disabled while the submit is in flight. No prop drilling.
dj-form-pending="hide" / ="show" on the spansThe "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:

  1. state(...) — declares comments as reactive; reassignment triggers diff + patch.
  2. @action — wraps post_comment so the template can read post_comment.pending / .error automatically.
  3. dj-form-pending — declarative in-flight form UX without a single line of client JS.
  4. dj-for — per-row diffing so adding one comment patches one <li>, not the whole list.
  5. @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 to self.comments before the DB write. The LISTEN echo 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 @action for delete_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.

Spotted a typo or want to improve this page? Edit on GitHub →