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

6 min read

Tutorial: Build a reusable LiveComponent

LiveViews answer "what does this URL render?" LiveComponents answer "what's a reusable interactive widget?" — a star rating, a tag picker, a code-editor pane, a notification bell. The same widget mounted in multiple parent views, each instance with its own state, all communicating up to their parent through a clean event channel.

If you've used React, this is roughly "function components with their own useState." If you've used Phoenix LiveView, it's exactly LiveComponent — same name, same shape.

By the end of this tutorial you'll have:

  • A StarRating component that takes a current rating + a read-only flag, renders 5 stars, and lets the user click to change the rating.
  • The component owns its own UI state (hover preview, active star) so the parent doesn't get re-rendered for every mouse move.
  • When the user commits a rating, the component bubbles a rating_changed event up to its parent view, which persists to the DB.
  • The same component dropped into two different parent views (a movie review page and a restaurant review page) without any copy-paste.
You'll learnDocumented in
LiveComponent lifecycle (mount, update, event handlers)Components
self.trigger_update() to re-render just this componentComponents
self.send_parent("event", payload) for child→parent commsComponents
When to use a Component (stateless) vs a LiveComponent (stateful)This tutorial

Prerequisites: Quickstart, the optimistic updates tutorial for context on the @event_handler shape. Familiarity with LiveView mount / event-handler patterns helps.


Component vs LiveComponent

Two flavors, picked by the question "does this widget have state?":

ComponentLiveComponent
StateNonePer-instance, lives across re-renders
EventsNonedj-click, dj-submit, etc.
Cost~1-10us per render (Rust)~50-100us per render (template)
Use forBadges, icons, status pills, formattersTabs, modals, ratings, pickers, anything interactive

The decision is binary: if the widget needs to remember anything between renders (selected tab, hovered star, draft text), it's a LiveComponent. Otherwise it's a Component and renders 50× faster.

This tutorial is about LiveComponents — Component is covered in the Components guide.


Step 1 — The component class

# myapp/components/star_rating.py
from djust import LiveComponent
from djust.decorators import event_handler


class StarRating(LiveComponent):
    template_name = "components/star_rating.html"

    def mount(self, **kwargs):
        # `kwargs` contains whatever the parent passed in via
        # {% live_component StarRating value=... %}
        self.value = kwargs.get("value", 0)
        self.max = kwargs.get("max", 5)
        self.read_only = kwargs.get("read_only", False)
        # Per-instance UI state — the parent never sees `hover`.
        self.hover = 0

    @event_handler
    def hover_star(self, n: int = 0, **kwargs):
        if self.read_only:
            return
        self.hover = n
        self.trigger_update()

    @event_handler
    def hover_clear(self, **kwargs):
        if self.read_only:
            return
        self.hover = 0
        self.trigger_update()

    @event_handler
    def commit_rating(self, n: int = 0, **kwargs):
        if self.read_only or n < 1 or n > self.max:
            return
        self.value = n
        self.hover = 0
        self.trigger_update()
        # Bubble up — the parent decides what to do (persist to DB,
        # log the action, congratulate the user, etc.).
        self.send_parent("rating_changed", {"value": n})

Three things to call out:

  1. mount(**kwargs) is called once when the parent mounts the component. kwargs is whatever was passed via {% live_component %}value=movie.rating, read_only=user.is_anonymous, etc. This is the component's only "props" channel.
  2. self.trigger_update() re-renders THIS component, not the parent view. Hover state changes 60 times as the user sweeps over the stars; if those triggered a parent re-render, the entire page would diff on every mouse-over.
  3. self.send_parent(event_name, payload) is the only way to talk back to the parent. The parent receives the event via a regular @event_handler named after event_name — no subscription wiring, no callbacks.

Step 2 — The component template

<!-- myapp/templates/components/star_rating.html -->
{% load djust_tags %}

<div class="star-rating {% if read_only %}is-read-only{% endif %}"
     dj-mouseleave="hover_clear" data-component-id="{{ component_id }}">
  {% for n in max|times %}
    {% with star_n=forloop.counter %}
      <button
        type="button"
        class="star {% if hover >= star_n or value >= star_n and hover == 0 %}is-filled{% endif %}"
        dj-mouseenter="hover_star"
        dj-payload-n="{{ star_n }}"
        dj-click="commit_rating"
        dj-payload-n-click="{{ star_n }}"
        data-component-id="{{ component_id }}"
        aria-label="Rate {{ star_n }} of {{ max }}"
        {% if read_only %}disabled{% endif %}
      ></button>
    {% endwith %}
  {% endfor %}
</div>

Two patterns:

PatternEffect
data-component-id="{{ component_id }}" on every interactive elementTells the framework which component instance the event targets. Required on each event-bound element.
dj-mouseleave="hover_clear" on the wrapperCleanup event — when the cursor leaves the rating row, hover preview resets. Without this, the last-hovered preview would stick after the cursor moved away.

The times filter (custom; trivial — range(value)) lets the template iterate over 1..max without computing in Python. Drop it if you'd rather pass stars=range(1, self.max + 1) from mount.


Step 3 — Drop it into a parent view

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

from .models import Movie


class MovieDetailView(LiveView):
    template_name = "movie_detail.html"

    def mount(self, request, *, movie_id: int, **kwargs):
        if not request.user.is_authenticated:
            self.redirect("/login/")
            return
        self.movie = Movie.objects.get(pk=movie_id)
        # Each user's own rating, if any
        self.user_rating = self.movie.ratings.filter(
            user=request.user,
        ).values_list("value", flat=True).first() or 0

    @action
    def rating_changed(self, value: int = 0, **kwargs):
        """Bubbled up from the StarRating LiveComponent."""
        self.movie.ratings.update_or_create(
            user=self.request.user,
            defaults={"value": value},
        )
        self.user_rating = value
<!-- myapp/templates/movie_detail.html -->
{% load djust_tags %}

<article class="movie">
  <h1>{{ movie.title }}</h1>
  <p>{{ movie.tagline }}</p>

  <div class="movie-rating">
    <p>Your rating</p>
    {% live_component "myapp.components.StarRating"
        value=user_rating
        max=5
        read_only=False %}
  </div>

  <div class="movie-rating">
    <p>Average ({{ movie.ratings_count }} reviews)</p>
    {% live_component "myapp.components.StarRating"
        value=movie.average_rating_int
        max=5
        read_only=True %}
  </div>
</article>

Two instances of the same component on one page:

  • The user's rating — interactive, mounted with the user's current value (or 0).
  • The community average — read-only, displays the rounded average.

When the user clicks a star in the first one, the component fires rating_changed upward. The parent's @action rating_changed runs, persists, and updates user_rating. Because the read-only instance doesn't depend on user_rating, it doesn't re-render — just the user's instance does (via the component's own trigger_update).


Step 4 — Drop the same component on a different page

The component is a Python class. Drop it onto any LiveView template:

<!-- myapp/templates/restaurant_detail.html -->
<article class="restaurant">
  <h1>{{ restaurant.name }}</h1>

  {% live_component "myapp.components.StarRating"
      value=user_rating
      max=5 %}

  {# ... rest of the restaurant page ... #}
</article>
# myapp/views.py
class RestaurantDetailView(LiveView):
    # ... mount as for MovieDetailView ...

    @action
    def rating_changed(self, value: int = 0, **kwargs):
        # Same handler shape; different model + persistence
        self.restaurant.ratings.update_or_create(
            user=self.request.user,
            defaults={"value": value},
        )
        self.user_rating = value

The component's hover state, click handlers, and template all work identically. The parent just decides what to do with the bubbled event. Two persistence backends, one widget.


Why no shared mutable state across instances

Each {% live_component %} invocation creates a fresh instance with its own self.value, self.hover, etc. Two instances of StarRating on the same page don't share state. That's deliberate — if they did, hovering one would highlight both.

If you DO want shared state (e.g. one component reflects another component's selection), wire it through the parent: child A fires an event, parent updates a state var, parent passes the new value to child B via the next {% live_component %} render. Don't try to peer-to-peer between siblings; the framework's event dispatch goes through the parent, and that's the only clean shape.


When to reach for a LiveComponent vs alternatives

You want…Use
One-off interactive UI on a single pageJust put it in the LiveView
Stateless reusable HTML (badges, icons, status pills)Component (faster, no state)
Stateful reusable widget used on multiple pagesLiveComponent
Same widget but the state lives on the parentPass value= + child fires events; don't store on the child
Cross-page state (cart, notifications)Parent LiveView state; mount component instances per page

The boundary that confuses people: when does a thing that COULD be a LiveComponent become one that SHOULD be?

  • Used on >1 page → yes, probably.
  • Has its own UI state that the parent doesn't need → yes.
  • Just markup + click handlers that talk to the parent → not worth the abstraction; inline it.

What just happened, end to end

   Browser                              Server
       │                                    │
       │ GET /movies/42/                   │
       │ ────────────────────────────────► MovieDetailView.mount()
       │                                    │   self.movie = Movie.objects.get(...)
       │                                    │   self.user_rating = 4
       │                                    │
       │                                    │ template renders:
       │                                    │   {% live_component StarRating value=4 %}
       │                                    │   → StarRating.mount(value=4)
       │                                    │     → hover=0
       │                                    │   → component renders 5 stars, 4 filled
       │ ◄ initial HTML ────────────────────│
       │                                    │
       │ user hovers star 5                 │
       │ ────────────────────────────────► StarRating.hover_star(n=5)
       │                                    │   self.hover = 5
       │                                    │   self.trigger_update()
       │ ◄ patch (5 stars filled) ──────────│   ← only the component re-renders
       │                                    │
       │ user clicks star 5                 │
       │ ────────────────────────────────► StarRating.commit_rating(n=5)
       │                                    │   self.value = 5
       │                                    │   self.hover = 0
       │                                    │   self.trigger_update()
       │                                    │   self.send_parent("rating_changed", {value: 5})
       │                                    │     ↓
       │                                    │   MovieDetailView.rating_changed(value=5)
       │                                    │     movie.ratings.update_or_create(...)
       │                                    │     self.user_rating = 5
       │                                    │   (no further re-render — component already updated)
       │ ◄ patch (component DOM only) ──────│

The hover events stay inside the component (60 patches/sec is fine because each one is a few bytes of class-toggle DOM). The commit travels up via send_parent and the parent persists.


Where to go next

  • Tabs panel: LiveComponent with selected_tab state + child slot rendering. Each tab's content is in the DOM but hidden via JS Commands. Switching tabs is purely client-side (JS Commands tutorial).
  • Notification bell: mounted in base.html so it appears on every page; subscribes to a Postgres LISTEN channel for new notifications (real-time comments tutorial pattern).
  • Form-field widgets: EmailField, PhoneField, MoneyField components with built-in validation. The parent form passes value= and listens for change events.
  • Component tests: the LiveViewTestClient (see Testing guide) supports mounting a component in isolation. Drop it into a fixture page in your test suite, drive it with simulated events, assert on the rendered HTML.
  • Don't forget Component for stateless cases. Status badges, date formatters, currency formatters, "loading" placeholders — all faster as Component because they don't need the LiveComponent lifecycle plumbing.

The three-method shape — mount for setup, @event_handler for interaction, send_parent for bubble-up — is the entire surface of LiveComponent. Once you've built one, the next ten are mostly copy-paste.

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