---
title: "Tutorial: Build a reusable LiveComponent"
slug: tutorial-live-component
section: guides
order: 72
level: intermediate
description: "Build a star-rating widget once, drop it onto any page, and have parent views react to its events. LiveComponents have their own state, lifecycle, and event handlers — like a LiveView but composable. Includes the right way for child components to talk back to their parents."
---

# 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 learn | Documented in |
|---|---|
| `LiveComponent` lifecycle (`mount`, `update`, event handlers) | [Components](/guides/components/) |
| `self.trigger_update()` to re-render just this component | [Components](/guides/components/) |
| `self.send_parent("event", payload)` for child→parent comms | [Components](/guides/components/) |
| When to use a `Component` (stateless) vs a `LiveComponent` (stateful) | This tutorial |

> **Prerequisites:** [Quickstart](/getting-started/), the [optimistic
> updates tutorial](/guides/tutorial-optimistic-updates/) 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](/guides/components/).

---

## Step 1 — The component class

```python
# 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)
        # Explicit star numbers — there is no `times` filter.
        self.stars = list(range(1, self.max + 1))
        # 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

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

<div class="star-rating {% if read_only %}is-read-only{% endif %}"
     dj-mouseleave="hover_clear" data-component-id="{{ component_id }}">
  {% for star_n in stars %}
    {% with n=star_n %}
      <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

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

```html
<!-- myapp/templates/movie_detail.html -->
{% load live_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:

```html
<!-- 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>
```

```python
# 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:** `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](/guides/tutorial-js-commands/)).
- **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](/guides/tutorial-real-time-comments/)
  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](/guides/testing/)) 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.
