Your First LiveView
On this page
Build a live counter — no page refreshes, no JavaScript to write.
What You'll Build
A counter with increment/decrement buttons that updates instantly via WebSocket. The entire feature is Python.
Before you start
Complete Installation first. Run commands below from
the directory containing manage.py, with the project environment activated
(or prefix them with .venv/bin/ or uv run).
The manual installation path already creates and registers myapp. If you
used djust new myproject, the generated starter app is named myproject;
create a separate app for this tutorial:
python manage.py startapp myapp
Add "myapp" to INSTALLED_APPS in your project's settings, and add
"myapp.views" to LIVEVIEW_ALLOWED_MODULES if your settings define it
(djust init does not; without the setting, installed apps are allowed). Keep the existing
entries. The URL step below connects this app to the site.
1. Create the View
Create myapp/views.py:
from djust import LiveView
from djust.decorators import event_handler
class CounterView(LiveView):
template_name = "myapp/counter.html"
def mount(self, request, **kwargs):
"""Initialize state here. Runs on the first HTTP render and again when the WebSocket connects."""
self.count = 0
def get_context_data(self, **kwargs):
"""Return template context. Called before every render."""
return {"count": self.count}
@event_handler()
def increment(self, **kwargs):
self.count += 1
@event_handler()
def decrement(self, **kwargs):
self.count -= 1
Key rules:
mount()runs on the initial HTTP render and again when the WebSocket connects (unless state is restored) — set initial state here, not in__init__, and keep it idempotent (no one-time side effects)- Every event handler needs
@event_handler()— djust blocks undecorated methods for security - Always accept
**kwargsin event handlers (djust may pass extra metadata) - State lives on
self— any change toself.counttriggers a re-render automatically
2. Create the Template
Create myapp/templates/myapp/counter.html:
<!DOCTYPE html>
<html>
<head>
<title>Counter</title>
{% load live_tags %}
{% djust_client_config %}
</head>
<body>
<div dj-root>
<h1>Count: {{ count }}</h1>
<button dj-click="decrement">-</button>
<button dj-click="increment">+</button>
</div>
</body>
</html>
Template requirements:
{% load live_tags %}and{% djust_client_config %}emit client config meta tags; djust auto-injects the client JS (~61 KB gz) into every LiveView responsedj-rootmarks the reactive region — only this subtree is patched on updates. It is the only root attribute you write; djust stampsdj-viewonto it server-side with the dotted path of the view rendering the page, which is what connects the page to the WebSocket session- (Write
dj-view="myapp.views.MyView"yourself only to name a specific view — an embedded or sticky view, or a template shared by several views. It is a literal path; there is nodj_view_idvariable) dj-click="increment"binds a click event to theincrementhandler
3. Add a URL
In myapp/urls.py:
from django.urls import path
from myapp.views import CounterView
urlpatterns = [
path("counter/", CounterView.as_view(), name="counter"),
]
In the project's myproject/urls.py, import include and add the app's
routes to the existing urlpatterns if they are not already included:
from django.urls import include, path
urlpatterns += [path("", include("myapp.urls"))]
A scaffolded project can keep its starter homepage. The new counter is at
/counter/.
4. Run It
python -m uvicorn myproject.asgi:application --reload
Visit http://localhost:8000/counter/ and click the buttons — the count updates instantly without a page reload.
How It Works
- The first request is a normal HTTP response (good for SEO and initial load)
- The page JS opens a WebSocket connection to
/ws/live/ - When you click a button, the client sends
{"type": "event", "event": "increment", "params": {}}over the WebSocket - djust calls your
increment()method, re-renders the template in Rust, diffs the VDOM, and sends only the changed HTML fragments back - The client patches the DOM — no full page reload
Responding to Input
For text inputs, use dj-input (fires on the input event, debounced 300 ms by default for text-like fields; override with dj-debounce/dj-throttle) or dj-change (fires on blur):
@event_handler()
def search(self, value: str = "", **kwargs):
"""The 'value' parameter receives the current input value."""
self.query = value
<input type="text" dj-input="search" value="{{ query }}" placeholder="Search..." />
<p>You searched for: {{ query }}</p>
Passing Data from the DOM
Use data-* attributes to pass data to event handlers:
@event_handler()
def delete_item(self, item_id: int = 0, **kwargs):
"""data-item-id="5" becomes item_id=5 (auto-converted to int)."""
self.items = [i for i in self.items if i["id"] != item_id]
{% for item in items %}
<li>
{{ item.name }}
<button dj-click="delete_item" data-item-id="{{ item.id }}">Delete</button>
</li>
{% endfor %}
Next Steps
- Core Concepts — understand the lifecycle and state model
- Forms — real-time form validation
- State Management — debouncing, loading states, optimistic updates
Try a real-world walkthrough
Once the counter is working, the four end-to-end tutorials apply the same primitives to common shipping patterns:
- Build a search-as-you-type feature — debounced single-input
- Build a real-time comment thread — multi-user broadcast
- Build a multi-step form wizard — stateful step cursor
- Build a typeahead with @server_function — partial-page server RPC