---
title: "Tutorial: Integrate Chart.js with a hook"
slug: tutorial-chart-hook
section: guides
order: 71
level: intermediate
description: "Drop a third-party JavaScript library (Chart.js, Mapbox, CodeMirror, anything) into a LiveView with a hook. Five lifecycle callbacks (mounted/updated/destroyed/disconnected/reconnected) cover every integration shape. Plus the right way to ship live data updates without re-mounting the chart on every patch."
---

# 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](/guides/hooks/) |
| Passing data to hooks via `data-*` attributes | [Hooks](/guides/hooks/) |
| `pushEvent()` from JS → server handler | [Hooks](/guides/hooks/) |
| When to use a hook vs a `@server_function` vs JS Commands | This tutorial |

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

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

```python
# 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")
    # The picker loops this instead of splitting a string in the template:
    # there is no `split` filter, and the presets already live here.
    ranges = state(default_factory=lambda: list(RANGE_PRESETS))

    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

```html
<!-- templates/dashboard.html -->
{% load live_tags %}

<section class="dashboard">
  <header class="dashboard-head">
    <h1>Revenue</h1>
    <div class="range-picker" role="group" aria-label="Time range">
      {% for r in ranges %}
        <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_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:

```javascript
// 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:**
  ```javascript
  mounted()    { this.map = new Map(this.el, …); }
  updated()    { this.map.setCenter(JSON.parse(this.el.dataset.center)); }
  destroyed()  { this.map.remove(); }
  ```
- **CodeMirror / Monaco editors:**
  ```javascript
  mounted()    { this.editor = new EditorView({ parent: this.el, … }); }
  updated()    { this.editor.dispatch({ changes: { … } }); }
  destroyed()  { this.editor.destroy(); }
  ```
- **Date pickers (Flatpickr, etc.):**
  ```javascript
  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.
