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
StarRatingcomponent 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_changedevent 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 learn | Documented in |
|---|---|
LiveComponent lifecycle (mount, update, event handlers) | Components |
self.trigger_update() to re-render just this component | Components |
self.send_parent("event", payload) for child→parent comms | Components |
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?":
Component | LiveComponent | |
|---|---|---|
| State | None | Per-instance, lives across re-renders |
| Events | None | dj-click, dj-submit, etc. |
| Cost | ~1-10us per render (Rust) | ~50-100us per render (template) |
| Use for | Badges, icons, status pills, formatters | Tabs, 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:
mount(**kwargs)is called once when the parent mounts the component.kwargsis whatever was passed via{% live_component %}—value=movie.rating,read_only=user.is_anonymous, etc. This is the component's only "props" channel.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.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_handlernamed afterevent_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:
| Pattern | Effect |
|---|---|
data-component-id="{{ component_id }}" on every interactive element | Tells the framework which component instance the event targets. Required on each event-bound element. |
dj-mouseleave="hover_clear" on the wrapper | Cleanup 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 page | Just put it in the LiveView |
| Stateless reusable HTML (badges, icons, status pills) | Component (faster, no state) |
| Stateful reusable widget used on multiple pages | LiveComponent |
| Same widget but the state lives on the parent | Pass 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:
LiveComponentwithselected_tabstate + child slot rendering. Each tab's content is in the DOM but hidden viaJS Commands. Switching tabs is purely client-side (JS Commands tutorial). - Notification bell: mounted in
base.htmlso it appears on every page; subscribes to a Postgres LISTEN channel for new notifications (real-time comments tutorial pattern). - Form-field widgets:
EmailField,PhoneField,MoneyFieldcomponents with built-in validation. The parent form passesvalue=and listens forchangeevents. - 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
Componentfor stateless cases. Status badges, date formatters, currency formatters, "loading" placeholders — all faster asComponentbecause they don't need theLiveComponentlifecycle 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.