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

5 min read

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 learnDocumented in
dj-hook + mounted()/updated()/destroyed()Client-Side JavaScript Hooks
Passing data to hooks via data-* attributesHooks
pushEvent() from JS → server handlerHooks
When to use a hook vs a @server_function vs JS CommandsThis 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:

CallbackWhenWhat to do
mounted()Element first enters the DOMInitialize the third-party library against this.el
updated()Element re-rendered with new contentUpdate the library in place — DON'T tear down + remount; you'll lose state and animations
destroyed()Element leaves the DOMFree 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:

PatternEffect
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_safe is the docs.djust.org-shipped filter for attribute-embedded JSON. If you don't have it, use Django's json_script template tag (which uses a <script> tag) plus a hook that reads from document.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 libraryJS Commands
Initialize a third-party library on an elementHook
Library + server data updatesHook 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.

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