---
title: "Client-Side JavaScript Hooks"
slug: hooks
section: guides
order: 5
level: intermediate
description: "Lifecycle callbacks for integrating charts, maps, editors, and custom DOM behavior"
---

# Client-Side JavaScript Hooks

djust hooks let you run custom JavaScript when elements are mounted, updated, or removed from the DOM. This is essential for integrating third-party libraries (charts, maps, editors) or any behavior that requires direct DOM access.

## What You Get

- **Lifecycle callbacks** -- `mounted()`, `updated()`, `destroyed()`, `disconnected()`, `reconnected()`, `beforeUpdate()`
- **Server communication** -- `pushEvent()` to send events, `handleEvent()` to receive server pushes
- **DOM access** -- `this.el` gives direct access to the hooked element
- **Automatic management** -- Hooks mount and destroy as the DOM changes via LiveView patches

## Quick Start

### 1. Register Hooks in JavaScript

```html
<script>
window.djust.hooks = Object.assign(window.djust.hooks || {}, {
    MyChart: {
        mounted() {
            this.chart = new Chart(this.el, {
                type: 'line',
                data: this.values.data,
            });
        },
        updated() {
            this.chart.data = this.values.data;
            this.chart.update();
        },
        destroyed() {
            this.chart.destroy();
        }
    }
});
</script>
```

### 2. Use `dj-hook` in Your Template

```html
<canvas dj-hook="MyChart" dj-hook-value-data='{{ chart_data_json }}'></canvas>
```

`dj-hook-value-data` is exposed on the instance as `this.values.data`,
JSON-parsed automatically — no manual `JSON.parse(this.el.dataset...)`.
See [Typed values & targets](#typed-values--targets) below for the full
coercion rules.

### 3. Communicate with the Server

```javascript
window.djust.hooks = Object.assign(window.djust.hooks || {}, {
    MapPicker: {
        mounted() {
            this.map = new MapLibrary(this.el);
            this.map.on('click', (e) => {
                this.pushEvent('location_selected', {
                    lat: e.lat, lng: e.lng
                });
            });

            this.handleEvent('center_map', (payload) => {
                this.map.setCenter(payload.lat, payload.lng);
            });
        },
        destroyed() {
            this.map.remove();
        }
    }
});
```

## Lifecycle Callbacks

| Callback | When Called |
|----------|------------|
| `mounted()` | Element first appears in the DOM. Initialize libraries, bind listeners. |
| `updated()` | After a server re-render patches the DOM. Sync libraries with new data. |
| `beforeUpdate()` | Before an incremental patch batch or full HTML morph, once per update within the updated root (including scoped child updates). Save state that would be lost (scroll position, selection). |
| `destroyed()` | Element removed from the DOM. Clean up libraries, observers, timers. |
| `disconnected()` | WebSocket connection drops. Show offline indicators. |
| `reconnected()` | WebSocket restored after disconnect. Refresh state. |

### Async lifecycle callbacks (v0.8.6+)

Lifecycle methods may be `async` (return a Promise). The dispatcher
detects Promise return and chains `.catch` to log rejections via
`console.error` — no Unhandled Promise Rejection in the browser console.

```javascript
window.djust.hooks.UserAvatar = {
    async mounted() {
        // Fetch profile data on mount; safe — rejection logs cleanly
        const res = await fetch(`/api/profile/${this.values.userId}`);
        const profile = await res.json();
        this.el.querySelector('img').src = profile.avatar_url;
    },
};
```

**Fire-and-forget contract**: the dispatcher does NOT await your async
hook. Lifecycle callbacks run in parallel with subsequent patches —
your hook's I/O can't block the render loop. If you need sequencing
across hook completion (e.g. "patch B must wait for hook A's fetch"),
coordinate explicitly via `pushEvent` + a server-side handler.

Sync hooks (no async/await, no Promise return) behave exactly as
before. This is purely additive: no API change for existing hook code.

## Integrating third-party libraries (Chart.js, maps, editors)

This is the canonical pattern for any client-side library that draws into or
manages its own DOM — Chart.js, Leaflet/MapLibre, CodeMirror, a rich-text
editor. **Use a hook. Do not init the library from an inline `<script>` inside
the reactive root.**

### The trap: inline `<script>` works on reload, blank after navigation

The intuitive approach is to drop a `<script>` next to the element, inside the
LiveView's reactive (`dj-view`) root:

```html
<!-- ❌ Works on a full page load, SILENTLY BLANK after dj-navigate -->
<canvas id="sales"></canvas>
<script>
    new Chart(document.getElementById('sales'), { /* ... */ });
</script>
```

On a hard reload this runs and the chart draws. But when you arrive via a
[`dj-navigate`](navigation#dj-navigate) SPA link, djust patches the new page in
over the WebSocket — and **scripts in morphed-in content do not execute**. The
library never initializes, the canvas stays blank, and **there is no error in
the console**. The confusing signature is *"reload works, navigation doesn't."*
If you've spent an hour on a chart that renders on refresh but is empty after
clicking a link, this is why.

### The fix: register a hook once in the persistent shell

Register the hook **once** in your base template (the persistent shell that
loads on every page and survives SPA navigation), and have the element opt in
via `dj-hook`. The hook's `mounted()` fires both on initial hydration **and**
every time the element is patched in via SPA navigation — so the library
initializes on every route.

```html
<!-- base.html — loaded once, OUTSIDE the per-page reactive content -->
<script>
window.DjustHooks = window.DjustHooks || {};
window.DjustHooks.Chart = {
    // The hook sits on a wrapper <div>; the canvas is an ignored child.
    mounted()   { this.draw(); },   // fires on hydration AND SPA patch-insert
    updated()   { this.draw(); },   // server re-rendered new data
    destroyed() {                   // element left the DOM — dispose
        const c = Chart.getChart(this.target('canvas'));
        if (c) c.destroy();
    },
    draw() {
        // Tear down a prior instance before redrawing (avoids Chart.js's
        // "Canvas is already in use" error on updated()/re-mount).
        const canvas = this.target('canvas');
        const prior = Chart.getChart(canvas);
        if (prior) prior.destroy();
        const data = this.values.chart;
        new Chart(canvas, {
            type: data.type,
            data: { labels: data.labels, datasets: [{ data: data.values }] },
        });
    },
};
</script>
```

```html
<!-- per-page template — the wrapper opts into the hook; the canvas is ignored -->
<div dj-hook="Chart" dj-hook-value-chart='{{ chart_json }}'>
    <canvas id="sales-chart" dj-update="ignore" dj-hook-target="canvas"></canvas>
</div>
```

> `window.DjustHooks` and `window.djust.hooks` are merged into one registry, so
> either works. `window.DjustHooks` is the Phoenix-LiveView-compatible name.

### Why `dj-update="ignore"`

Chart.js (and maps, editors, …) mutate the element's own DOM — a `<canvas>`
gets a sized drawing buffer, a map fills a `<div>` with tiles. djust's VDOM has
no idea those mutations happened and would fight the library on the next patch,
wiping its work. `dj-update="ignore"` tells the patcher to **skip this element
entirely** on server re-renders, leaving the subtree fully under the library's
control. You drive updates yourself from `updated()` (reading the live
`this.values` proxy), not from the VDOM.

Two details make this work:

- **Give the ignored element an `id`.** On full-HTML updates the client skips
  any `dj-update` element without one and logs "Element with dj-update must
  have an id".
- **Keep `dj-hook-value-*` off the ignored element.** On a full morph an
  ignored element's attributes are frozen too, so a value attribute on the
  canvas itself would keep its first value and `this.values` would go stale.
  Put the hook and its values on a non-ignored wrapper, as above, and find the
  canvas with `this.target('canvas')`.

> For elements where you want the VDOM to keep patching *most* attributes but
> leave a specific few alone (e.g. a `<dialog open>` toggled by the browser),
> use [`dj-ignore-attrs`](#ignored-attributes-dj-ignore-attrs--v050) instead —
> it's a per-attribute opt-out rather than a whole-element one.

### Cleanup matters

`destroyed()` fires when the element leaves the DOM (including when you navigate
*away* via `dj-navigate`). Disposing the library instance there prevents leaked
canvases, dangling map listeners, and Chart.js's "Canvas is already in use"
error when you navigate back. See [Best Practices](#best-practices) below.

## Hook Instance API

> **Reserved names**: `el`, `viewName`, `values`, `target`, `targets`,
> `pushEvent`, `handleEvent`, `js`, and `_eventHandlers` are framework-owned properties that
> djust assigns on every hook instance (see `this.values` / `this.target()`
> below). If your hook definition declares a member with one of these
> names — `{ values: [], mounted() {...} }`, or a method named `target` —
> the framework's own property silently shadows it. There is no warning at
> registration or mount time; your member is simply never called. Pick a
> different name for hook-owned state (e.g. `this._chart`, `this.data`).

### Properties

| Property | Type | Description |
|----------|------|-------------|
| `this.el` | `HTMLElement` | The DOM element with `dj-hook`. Updated automatically on patches. |
| `this.viewName` | `string` | LiveView name from closest `[dj-view]` ancestor. |

See [Typed values & targets](#typed-values--targets) below for
`this.values`, `this.target()`, and `this.targets()`.

### Methods

**`this.pushEvent(event, payload)`** -- Send an event to the server LiveView.

```javascript
this.pushEvent('item_clicked', { id: this.values.itemId });
```

**`this.handleEvent(eventName, callback)`** -- Listen for server push events.

```javascript
this.handleEvent('highlight', (payload) => {
    this.el.style.backgroundColor = payload.color;
});
```

## Typed values & targets

Passing data to a hook used to mean raw `dataset` strings and hand-rolled
parsing. `dj-hook-value-*` attributes are exposed on the instance as
`this.values.*` with JSON-first coercion:

```html
<canvas dj-hook="Chart"
        dj-hook-value-points="[1,2,3]"
        dj-hook-value-animated="true"
        dj-hook-value-title="Sales"></canvas>
```

```javascript
window.djust.hooks = Object.assign(window.djust.hooks || {}, {
    Chart: {
        mounted() {
            this.values.points     // [1, 2, 3]  — Array
            this.values.animated   // true       — Boolean
            this.values.title      // "Sales"    — string (JSON.parse failed → raw)
            renderChart(this.el, this.values);
        },
        updated() {
            // values are LIVE: after a server re-render changes the
            // attributes, reads here see the fresh values automatically.
            renderChart(this.el, this.values);
        },
    },
});
```

The coercion rule: try `JSON.parse`, fall back to the raw string. A literal
string that *looks* like JSON must be JSON-quoted:
`dj-hook-value-code='"007"'` → `"007"` (not the number 7). Attribute names
are kebab-case, property names camelCase
(`dj-hook-value-points-per-page` → `this.values.pointsPerPage`). `this.values`
is read-only — update the attribute server-side and let the morph carry it.
Access values by name only — the object is not enumerable (`Object.keys(this.values)`/spread return nothing in v1).

Named descendants use `dj-hook-target`:

```html
<div dj-hook="Player">
    <video dj-hook-target="video"></video>
    <button dj-hook-target="mute">🔇</button>
</div>
```

```javascript
this.target('video')   // first match (Element or null)
this.targets('mute')   // all matches (Element[])
```

Lookups are live subtree queries scoped to `this.el`. Note: descendants of a
*nested* hook are not excluded in v1 — prefer unique target names when
nesting hooks.

### Before / after

What the same hook looked like on raw `dataset` strings, and with typed
values + targets:

```javascript
// BEFORE: every hook re-invented this
mounted() {
    const points = JSON.parse(this.el.dataset.points || "[]");
    const animated = this.el.dataset.animated === "true";
    const canvas = this.el.querySelector("#chart-canvas-" + this.el.id); // unique IDs…
}

// AFTER
mounted() {
    new Chart(this.target('canvas'), {
        data: this.values.points,                     // [1,2,3] — real Array
        options: {animation: this.values.animated},   // real Boolean
    });
}
```

### Recipe: live values make `updated()` trivial

```python
# server: any handler that changes the data
@event_handler()
def set_period(self, value: str = "", **kwargs):
    self.points_json = json.dumps(self.compute(value))
```

```javascript
updated() {
    // the morph already updated the attribute; this.values reads it LIVE
    this.chart.data.datasets[0].data = this.values.points;
    this.chart.update();
}
```

No snapshotting, no staleness — the values object reads the current DOM
attribute on every access.

### Recipe: a countdown that survives re-renders

```html
<div dj-hook="Countdown" dj-hook-value-deadline='"2026-08-01T00:00:00Z"'>
    <span dj-hook-target="display"></span>
</div>
```

```javascript
window.djust.hooks.Countdown = {
    mounted() {
        this.timer = setInterval(() => {
            const left = new Date(this.values.deadline) - Date.now();
            this.target('display').textContent = formatDuration(left);
        }, 1000);
    },
    destroyed() { clearInterval(this.timer); },
};
```

The server moves the deadline → the morph updates the attribute → the next
tick reads the new value. Nothing else to wire. (Note the JSON-quoted
attribute value — an ISO timestamp parses as a plain string either way, but
quoting makes the intent explicit.)

To *drive* a hook-owned library instance from Python between renders —
"jump to line 42", "highlight the Q3 data point" — pair hooks with
[custom JS commands](js-commands#custom-commands): the hook owns the
instance, a registered command manipulates it, and the server binds the
chain to any event attribute.

### Pre-1.1 hooks (dataset attributes)

Hooks written before typed values (pre-1.1) commonly read raw `data-*`
attributes off `this.el.dataset` and parsed them by hand:

```html
<canvas dj-hook="Chart" data-values='{{ chart_data_json }}'></canvas>
```

```javascript
window.djust.hooks.Chart = {
    mounted() {
        const data = JSON.parse(this.el.dataset.values);
        this.chart = new Chart(this.el, { type: 'line', data });
    },
    updated() {
        this.chart.data = JSON.parse(this.el.dataset.values);
        this.chart.update();
    },
};
```

**This still works, unmodified.** `dj-hook`, the lifecycle callbacks, and
`this.el` haven't changed — `this.el.dataset` is plain DOM, not a djust
API, so nothing about typed values deprecates it. New hooks should still
prefer `dj-hook-value-*` + `this.values` (above): no manual `JSON.parse`,
live reads on `updated()`, and no risk of shadowing the reserved
`this.values` name (see [Reserved names](#hook-instance-api) above) by
also using it for something else.

## TypeScript & editor support

djust ships ambient type declarations for the public `window.djust` surface
(hook lifecycle + `this` context — including `values` / `target()` /
`targets()` — transports, uploads, streaming, model binding, and
`commands.register`). Point your editor at them — no build step needed:

```json
// jsconfig.json
{
    "compilerOptions": { "checkJs": true },
    "include": ["static/**/*.js", "./typings/djust.d.ts"]
}
```

Locate (or copy) the shipped file with:

```bash
python -c "import djust, pathlib; print(pathlib.Path(djust.__file__).parent / 'static/djust/djust.d.ts')"
```

## Example: Chart.js Integration

```python
import json
from djust import LiveView
from djust.decorators import event_handler

class DashboardView(LiveView):
    template_name = 'dashboard.html'

    def mount(self, request, **kwargs):
        self.sales_data = self.fetch_sales()

    @event_handler()
    def refresh_data(self, **kwargs):
        self.sales_data = self.fetch_sales()

    def get_context_data(self, **kwargs):
        return {'chart_json': json.dumps(self.sales_data)}
```

```html
<canvas dj-hook="SalesChart" dj-hook-value-chart='{{ chart_json }}'></canvas>
<button dj-click="refresh_data">Refresh</button>

<script>
window.djust.hooks = Object.assign(window.djust.hooks || {}, {
    SalesChart: {
        mounted() {
            const data = this.values.chart;
            this.chart = new Chart(this.el, {
                type: 'bar',
                data: {
                    labels: data.labels,
                    datasets: [{ label: 'Sales', data: data.values }]
                }
            });
        },
        updated() {
            const data = this.values.chart;
            this.chart.data.labels = data.labels;
            this.chart.data.datasets[0].data = data.values;
            this.chart.update();
        },
        destroyed() {
            this.chart.destroy();
        }
    }
});
</script>
```

## Example: Infinite Scroll

```html
<div id="items">
    {% for item in items %}
    <div class="item">{{ item.name }}</div>
    {% endfor %}
</div>
<div dj-hook="InfiniteScroll" dj-hook-value-page="{{ page }}"></div>

<script>
window.djust.hooks = Object.assign(window.djust.hooks || {}, {
    InfiniteScroll: {
        mounted() {
            this.observer = new IntersectionObserver((entries) => {
                if (entries[0].isIntersecting) {
                    // this.values.page is already a Number (JSON-coerced) —
                    // no parseInt needed.
                    const page = this.values.page + 1;
                    this.pushEvent('load_more', { page });
                }
            });
            this.observer.observe(this.el);
        },
        updated() {
            this.observer.observe(this.el);
        },
        destroyed() {
            this.observer.disconnect();
        }
    }
});
</script>
```

## Best Practices

- **Always clean up in `destroyed()`**: Destroy library instances, disconnect observers, remove global listeners, clear timers.
- **Do not create new instances in `updated()`**: Only refresh existing ones. Creating new instances on every re-render causes memory leaks.
- **Pass data via `dj-hook-value-*` attributes**: use `this.values` for JSON-typed data (arrays, objects, numbers, booleans) — no manual `JSON.parse`. Reserve plain `data-*` / `this.el.dataset` for [pre-1.1 hooks](#pre-11-hooks-dataset-attributes) you haven't migrated yet.
- **Use `pushEvent` / `handleEvent`** for server communication, not direct WebSocket calls.
- **Register hooks before mount**: Place hook definitions in `<head>` or before the djust client script. Hooks registered late are picked up on the next DOM patch. Before client.js loads, `window.djust` does not exist yet, so use `window.DjustHooks = Object.assign(window.DjustHooks || {}, { ... })` there. After client.js, merge into `window.djust.hooks` with `Object.assign` rather than assigning a new object, which would discard hooks already registered (including the built-in `CursorOverlay` and any `{% colocated_hook %}` hooks).

## Colocated Hooks (v0.5.0, Phoenix 1.1 parity)

Write hook JavaScript inline with the template that uses it, instead of in a separate file. The `{% colocated_hook %}` block tag emits a `<script type="djust/hook" data-hook="NAME">` tag; the djust client walks these on init and after each VDOM morph and registers each body as `window.djust.hooks[NAME]`.

```django
{% load live_tags %}

{% colocated_hook "Chart" %}
    hook.mounted = function() {
        const data = this.values.data;
        this.chart = new Chart(this.el, { type: 'line', data });
    };
    hook.updated = function() {
        this.chart.data = this.values.data;
        this.chart.update();
    };
    hook.destroyed = function() {
        this.chart.destroy();
    };
{% endcolocated_hook %}

<canvas dj-hook="Chart" dj-hook-value-data='{{ chart_data_json }}'></canvas>
```

**Convention**: your body assigns callbacks to a local `hook` object. The client wraps the body in an IIFE factory (`(function() { const hook = {}; <body>; return hook; })()`) and stores the result under `data-hook`.

### Namespacing

Two different views that each define `Chart` will collide if both register the hook globally. Opt into namespacing to prefix each hook with the view's module + class name:

```python
# settings.py
DJUST_CONFIG = {"hook_namespacing": "strict"}
```

With strict namespacing on, `{% colocated_hook "Chart" %}` inside a template rendered by `myapp.views.DashboardView` emits `data-hook="myapp.views.DashboardView.Chart"`, and the `<canvas dj-hook="myapp.views.DashboardView.Chart">` selector must match. Set `dj-hook` to the same namespaced name — typically this is done via a Django context variable or a helper that knows the current view.

Per-tag opt-out when a hook truly is global (e.g. a page-wide tooltip handler):

```django
{% colocated_hook "Tooltip" global %}
    hook.mounted = function() { /* ... */ };
{% endcolocated_hook %}
```

The `global` keyword emits the bare `data-hook="Tooltip"` regardless of the `hook_namespacing` setting.

### Security

- The hook body is **template-author JS**, not user input. Never interpolate request data into the body.
- The `{% colocated_hook %}` tag escapes `</script>` / `</SCRIPT>` in the body to prevent premature tag close.
- The client evaluates bodies via `new Function(...)`. Apps on strict CSP without `'unsafe-eval'` should skip this feature and register hooks via a nonce-bearing `<script>window.djust.hooks.X = {...}</script>`.
- Every emitted script carries a `/* COLOCATED HOOK: <name> */` banner so security auditors can grep for them.

## Ignored Attributes (`dj-ignore-attrs`) — v0.5.0

Mark specific HTML attributes as client-owned so VDOM `SetAttr` patches skip them. Essential for browser-native elements (`<dialog>`, `<details>`) and third-party JS libraries that manage attributes the server doesn't know about. Phoenix 1.1 calls this `JS.ignore_attributes/1`.

```html
<!-- Browser manages <dialog open> via .showModal() / .close() -->
<dialog id="confirm" dj-ignore-attrs="open">...</dialog>

<!-- Third-party library manages data-state + aria-expanded -->
<div dj-ignore-attrs="data-state, aria-expanded" ...></div>
```

**Format**: comma-separated list of attribute names. Whitespace around commas is tolerated.

**Scope**: on incremental patches only `SetAttr` is skipped (`RemoveAttr` still applies); full morphs neither set nor remove the listed attributes. Users explicitly opt in per element; there is no framework-level default list. Rationale: the browser-native case is common, but silent magic is a bigger cost than the typing.
