---
title: "Tutorial: Build a real-time comment thread"
slug: tutorial-real-time-comments
section: guides
order: 42
level: intermediate
description: "Build a comment thread that streams new posts to every reader the moment they're written — no polling, no manual refresh. Combines @action for the post handler, dj-form-pending for the submit UX, a keyed list for rendering, and database notifications for the live broadcast."
---

# 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** as a keyed list (`dj-key`), so
  the list grows by patching in the new row rather than re-rendering
  the others.
- **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 learn | Documented in |
|---|---|
| Server actions for form submissions | [Server Actions](server-actions.md) |
| Pending UX during submit | [Loading States](loading-states.md) |
| Rendering keyed lists | [Keyed lists (`dj-key`)](keyed-lists-performance.md) |
| Live cross-user broadcasts | [Database Notifications](database-notifications.md) |

> **Prerequisites:** The [quickstart](../getting-started/first-liveview.md), a working
> Django project with djust, a configured database (PostgreSQL for the
> live-update step), and a Django user model. Familiarity with
> [`@event_handler`](server-actions.md) 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:

```python
# 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:

```python
# myapp/views.py
from djust import LiveView, action, state

from .models import Comment, Post


class CommentThreadView(LiveView):
    template_name = "comment_thread.html"

    login_required = True  # anonymous visitors go to settings.LOGIN_URL

    post_id = state(0)
    comments = state(default_factory=list)

    def mount(self, request, *, post_id: int, **kwargs):
        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.error` and
   `post_comment.result` without any extra wiring. (The template
   renders after the handler returns, so in-flight UX comes from
   `dj-form-pending`, not from `.pending`.)
3. Lets you pair with `dj-form-pending` for declarative in-flight
   form UX (see Step 3).

---

## Step 3 — The template

```html
<!-- myapp/templates/comment_thread.html -->
<section dj-root>
  <h2>Comments on this post ({{ comments|length }})</h2>
  <hr />

  <ul class="comments">
    {% for comment in comments %}
      <li dj-key="{{ comment.id }}">
        <strong>@{{ comment.author }}</strong>
        <time datetime="{{ comment.created_at }}">{{ comment.created_at }}</time>
        <p>{{ comment.body }}</p>
      </li>
    {% endfor %}
  </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:

| Element | Behavior |
|---|---|
| `dj-root` on the outer `<section>` | Marks the reactive region. djust stamps `dj-view` onto it when it renders the page, and the client connects to that element. Without it the page renders but never connects: the form does a plain browser submit and no comment ever streams in. |
| `{% for %}` with `dj-key="{{ comment.id }}"` | Keyed diffing: when `self.comments` grows by one, only that one `<li>` is inserted into the DOM. The other rows are matched by key and left alone. |
| `<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

```python
# 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>'`:

```python
# 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()`:

```python
class CommentThreadView(LiveView):
    # ... as before ...

    def mount(self, request, *, post_id: int, **kwargs):
        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  │
      │                       │ handle_info()         │
      │                       │   (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.error` / `.result` automatically.
3. **`dj-form-pending`** — declarative in-flight form UX without a
   single line of client JS.
4. **`dj-key`** — keyed diffing so adding one comment inserts 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](database-notifications.md) for
   the full primitive reference.

---

## Where to go next

- **"Who's reading right now":** add the [Presence](presence.md)
  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 the
  [infinite scroll tutorial](tutorial-infinite-scroll.md).
- **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:** `@notify_on_save` uses one fixed channel
  per model, and its payload carries only `pk`, `event` and `model`.
  For very high write volume, send per-post notifications yourself
  with `send_pg_notify(f"myapp_comment_{comment.post_id}", {...})`
  and have each view `listen()` only to its own post's channel. See
  [Database Notifications](database-notifications.md).
- **Soft-deletes & moderation:** add an `@action` for
  `delete_comment(comment_id)` guarded by
  `@permission_required("myapp.delete_comment")`, 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.
