Tutorial: Integrate Chart.js with a hook
Eventually every real app needs a chart, a map, a code editor, a date picker, or something else built by people who weren't thinking about LiveView. djust's answer is hooks — small JavaScript objects that mount when an element appears in the DOM and clean up when it leaves. The framework handles mount/update/unmount lifecycle; you write the integration code.
By the end of this tutorial you'll have:
- A live revenue dashboard with a Chart.js line chart that re-draws when the underlying data changes — without re-mounting the chart, so animations stay smooth.
- A time-range selector (1h / 24h / 7d / 30d) that triggers a server fetch and patches new data into the chart.
- A destroy callback so navigating away frees the chart's WebGL context and prevents memory leaks.
- The same pattern applied to a Mapbox map and a CodeMirror editor in the "Where to go next" section, so the recipe sticks.
| You'll learn | Documented in |
|---|---|
dj-hook + mounted()/updated()/destroyed() | Client-Side JavaScript Hooks |
Passing data to hooks via data-* attributes | Hooks |
pushEvent() from JS → server handler | Hooks |
When to use a hook vs a @server_function vs JS Commands | This tutorial |
Prerequisites: Quickstart, the optimistic updates tutorial (recommended). A basic understanding of Chart.js helps but isn't required — swap in your charting library of choice.
What you're building
┌────────────────────────────────────────────────────────┐
│ Revenue [1h][24h][7d][30d] │
│ ^ selected │
│ │
│ $/day │
│ ┃ ╱╲ │
│ ┃ ╱─╮╱ ╲ │
│ ┃ ╱──╯ ╯ ╲ │
│ ┃ ╱──╯ ╲│
│ ┃ ╱─────╯ │
│ ┃ ╱──────╯ │
│ ┃ ╱──╯ │
│ ┃ ╱──╯ │
│ ┃ ╱────╯ │
│ ┃ │
│ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
│ Apr 20 Apr 22 Apr 24 Apr 26 Apr 28 │
└────────────────────────────────────────────────────────┘
Click a different range button → the same chart instance
animates to the new data. Chart.js animation runs locally; the
server only fired the set_range event and pushed back a new
data payload.
Step 1 — Define the hook
<!-- templates/base.html (or wherever you load js) -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<script>
window.djust.hooks = window.djust.hooks || {};
window.djust.hooks.RevenueChart = {
mounted() {
const data = JSON.parse(this.el.dataset.points);
this.chart = new Chart(this.el, {
type: "line",
data: {
labels: data.map(p => p.date),
datasets: [{
label: "Revenue",
data: data.map(p => p.value),
borderColor: "#3b82f6",
tension: 0.3,
}],
},
options: {
responsive: true,
animation: { duration: 400 },
scales: { y: { beginAtZero: true } },
},
});
},
updated() {
// Called when this element gets re-rendered AND the patch
// touched something inside it. We update the chart in place
// instead of destroying and recreating, so the animation
// tweens the new values.
const data = JSON.parse(this.el.dataset.points);
this.chart.data.labels = data.map(p => p.date);
this.chart.data.datasets[0].data = data.map(p => p.value);
this.chart.update();
},
destroyed() {
// Free the WebGL/canvas context. Without this, navigating
// away from the page leaves the chart in memory until GC
// catches up — fine for one chart, bad for a dashboard
// page that mounts ten.
this.chart.destroy();
},
};
</script>
Three lifecycle callbacks cover the entire integration:
| Callback | When | What to do |
|---|---|---|
mounted() | Element first enters the DOM | Initialize the third-party library against this.el |
updated() | Element re-rendered with new content | Update the library in place — DON'T tear down + remount; you'll lose state and animations |
destroyed() | Element leaves the DOM | Free resources (WebGL contexts, event listeners, timers) |
disconnected() and reconnected() exist too, for handling
WebSocket reconnects — useful for libraries that need to
re-establish their own connections (live-streaming charts, peer-
to-peer maps). Most static integrations don't need them.
Step 2 — The view
# myapp/views.py
from datetime import timedelta
from django.utils import timezone
from djust import LiveView, state, event_handler
from .models import Revenue
RANGE_PRESETS = {
"1h": timedelta(hours=1),
"24h": timedelta(days=1),
"7d": timedelta(days=7),
"30d": timedelta(days=30),
}
class RevenueDashboardView(LiveView):
template_name = "dashboard.html"
points = state(default_factory=list)
selected_range = state("7d")
def mount(self, request, **kwargs):
self._refresh()
def _refresh(self):
delta = RANGE_PRESETS[self.selected_range]
cutoff = timezone.now() - delta
rows = Revenue.objects.filter(date__gte=cutoff).order_by("date")
self.points = [
{"date": r.date.isoformat(), "value": float(r.amount)}
for r in rows
]
@event_handler
def set_range(self, range: str = "", **kwargs):
if range not in RANGE_PRESETS:
return
self.selected_range = range
self._refresh()
When set_range fires, two state vars change (selected_range
and points). The framework patches both into the DOM — the
range buttons update their is-active class, and the chart
element's data-points attribute changes. The updated() hook
sees the attribute change and re-runs.
Step 3 — The template
<!-- templates/dashboard.html -->
{% load djust_tags %}
<section class="dashboard">
<header class="dashboard-head">
<h1>Revenue</h1>
<div class="range-picker" role="group" aria-label="Time range">
{% for r in "1h,24h,7d,30d"|split:"," %}
<button
type="button"
dj-click="set_range"
data-range="{{ r }}"
class="range-btn {% if selected_range == r %}is-active{% endif %}"
aria-pressed="{{ selected_range == r|yesno:'true,false' }}"
>{{ r }}</button>
{% endfor %}
</div>
</header>
<canvas
id="revenue-chart"
dj-hook="RevenueChart"
data-points='{{ points|json_script_safe }}'
aria-label="Revenue chart for the {{ selected_range }} range"
></canvas>
</section>
Two patterns:
| Pattern | Effect |
|---|---|
dj-hook="RevenueChart" | Mount the RevenueChart hook on this element. |
data-points='{{ points|json_script_safe }}' | Serialize Python list to JSON-in-attribute. The json_script_safe filter handles HTML-escaping so embedded </script> and quotes can't break the page. The hook reads it via JSON.parse(this.el.dataset.points). |
json_script_safeis the docs.djust.org-shipped filter for attribute-embedded JSON. If you don't have it, use Django'sjson_scripttemplate tag (which uses a<script>tag) plus a hook that reads fromdocument.getElementById(...).
Step 4 — Why update-in-place beats remount
A naive integration would tear down + recreate the chart on every
updated(). Don't:
// BAD — flickers, loses animation, leaks contexts under load
updated() {
this.chart.destroy();
this.mounted(); // call mount again to rebuild
}
Chart.js (and most chart libraries) animate between the old
and new datasets. If you destroy the chart, the animation is lost
— the new data appears abruptly. With update()-in-place, the
line tweens from old shape to new shape over 400 ms.
The same applies to maps (smooth pan/zoom > snap to new center), editors (cursor position survives > caret jumps to top), video players (state preserves > playback restarts), and generally any library where "feels alive" matters.
The contract: mounted() allocates, updated() mutates,
destroyed() frees.
Step 5 — When to reach for a hook vs other primitives
| You want… | Use |
|---|---|
| Click → server state change → re-render | @event_handler |
| Just-fetch-data, no re-render | @server_function |
| Pure client DOM ops, no server, no library | JS Commands |
| Initialize a third-party library on an element | Hook |
| Library + server data updates | Hook with updated() |
| Library + library calls server (e.g. map click → save pin) | Hook with pushEvent() |
The rule of thumb: if you'd npm install a library to do this
in a React project, it's a hook in djust. If you wouldn't (it's a
state change, a server fetch, or pure DOM toggling), it's one of
the other three primitives.
What just happened, end to end
Browser Server
│ │
│ initial render: <canvas dj-hook="RevenueChart"
│ data-points="[…]">
│ ─── hook.mounted() runs ───────► │
│ new Chart(canvas, …) │
│ (chart visible) │
│ │
│ click "30d" button │
│ ───────────────────────────────────────► set_range("30d")
│ │ self.selected_range = "30d"
│ │ self._refresh()
│ │ → self.points = [...]
│ ◄─── patch: button class flip, │
│ data-points attr changes ───────────│
│ │
│ DOM mutated → hook.updated() fires │
│ chart.data.labels = ... │
│ chart.data.datasets[0].data = ... │
│ chart.update() │
│ (chart animates to new data over 400ms) │
│ │
│ user navigates away │
│ <canvas> removed from DOM │
│ hook.destroyed() fires │
│ chart.destroy() → frees WebGL ctx │
mounted() runs once. updated() runs every time the server
patches the element. destroyed() runs once on removal. That's
the whole lifecycle.
Where to go next
The same recipe applies to other libraries — mounted allocates,
updated mutates, destroyed frees:
- Mapbox / Leaflet maps:
mounted() { this.map = new Map(this.el, …); } updated() { this.map.setCenter(JSON.parse(this.el.dataset.center)); } destroyed() { this.map.remove(); }
- CodeMirror / Monaco editors:
mounted() { this.editor = new EditorView({ parent: this.el, … }); } updated() { this.editor.dispatch({ changes: { … } }); } destroyed() { this.editor.destroy(); }
- Date pickers (Flatpickr, etc.):
mounted() { this.picker = flatpickr(this.el, …); } destroyed() { this.picker.destroy(); }
Two lifecycle callbacks worth knowing about:
disconnected()/reconnected()for WebSocket reconnects — useful when the library has its own connection (live charts pulling SSE, video streams). Pause + resume rather than destroy + remount.beforeUpdate()if you need to capture state from the DOM before the server's patch lands — e.g. preserve the user's current scroll position in a chat hook so re-renders don't yank them away.
The five-callback shape is small enough to memorize. Any
JavaScript library that lets you new Library(element, options)
fits this pattern. Once it clicks, "do I bring in this library?"
stops being a framework question and becomes a normal
size-vs-need tradeoff.