djust docs
Browse documentation

Events

On this page

djust uses dj-* HTML attributes to bind server-side Python handlers to client-side DOM events.

Event Bindings

AttributeFires whenHandler receives
dj-click="handler"Element is clickeddata-* attrs as kwargs
dj-input="handler"Input value changes (input event; text inputs debounced 300 ms by default)value= string, _target= name
dj-change="handler"Input/select loses focusvalue= string, _target= name
dj-submit="handler"Form is submittedAll named fields as kwargs
dj-keydown.enter="handler"Enter key pressed**kwargs
dj-keydown.escape="handler"Escape key pressed**kwargs
dj-window-keydown="handler"Keydown on windowkey, code + dj-value-*
dj-document-click="handler"Click on documentclientX, clientY + dj-value-*
dj-click-away="handler"Click outside elementdj-value-* attrs as kwargs
dj-shortcut="bindings"Keyboard shortcut matchedkey, code, shortcut
dj-mounted="handler"Element enters DOM (after patch)dj-value-* attrs as kwargs

Defining Handlers

All event handlers must use the @event_handler() decorator. djust blocks any method not decorated with it (security):

from djust.decorators import event_handler

class MyView(LiveView):
    @event_handler()
    def increment(self, **kwargs):
        self.count += 1

    @event_handler()
    def search(self, value: str = "", **kwargs):
        """dj-input/dj-change: current input value arrives as 'value'."""
        self.query = value

    @event_handler()
    def delete(self, item_id: int = 0, **kwargs):
        """data-item-id="{{ item.id }}" becomes item_id=5 (auto-coerced)."""
        self.items = [i for i in self.items if i["id"] != item_id]

    @event_handler()
    def save(self, **form_data):
        """dj-submit: all named form fields arrive as kwargs."""
        name = form_data.get("name", "")
        email = form_data.get("email", "")

Rules (the default, legacy parameter policy; for closed, typed signatures see Typed event parameters):

  • Declare the parameters the binding sends; manage.py check reports a binding its handler would reject (djust.T020)
  • Under the legacy policy, dj-input and dj-change also send field and _target, and dj-submit sends _target with the form fields: keep **kwargs on those handlers, or declare the names
  • Give a parameter a default when a binding may omit it (value: str = "")
  • Use type hints for automatic coercion (item_id: int converts "5" → 5)
  • value is the magic parameter name for dj-input and dj-change

Template Bindings

Click

<!-- Simple click -->
<button dj-click="increment">+</button>

<!-- Pass data via data-* attributes -->
<button dj-click="delete" data-item-id="{{ item.id }}">Delete</button>

data-* attributes are converted: data-item-id → item_id. See Data Attribute Naming Convention for full details.

Inline handler arguments

For literal one-shot values, you can pass arguments to the handler inline using function-call syntax in the directive — no data-* attribute needed:

<button dj-click="set_period('month')">Monthly</button>
<button dj-click="set_period('year')">Yearly</button>

<!-- Multiple positional args -->
<button dj-click="add_to_cart('SKU-42', 3)">Add 3</button>

<!-- Mixed literal types: strings, numbers, booleans, null -->
<button dj-click="apply_filters('active', 18, true, null)">Adults only</button>

The handler receives the args positionally:

@event_handler
def set_period(self, period: str):
    self.period = period

@event_handler
def add_to_cart(self, sku: str, quantity: int):
    ...

Use data-* attributes when the value comes from a template expression ({{ item.id }}); use inline args when the value is a literal known at template-author time. (Available since v0.1.7.)

Input

<!-- Fires 300 ms after the user stops typing (override with dj-debounce) -->
<input type="text" dj-input="search" value="{{ query }}" placeholder="Search..." />

Change (Select / Blur)

<select dj-change="filter_status">
    <option value="all" {% if status == "all" %}selected{% endif %}>All</option>
    <option value="active" {% if status == "active" %}selected{% endif %}>Active</option>
</select>

Form Submit

<form dj-submit="save_form">
    {% csrf_token %}
    <input name="title" type="text" value="{{ title }}" />
    <input name="email" type="email" value="{{ email }}" />
    <button type="submit">Save</button>
</form>

{% csrf_token %} is required in all dj-submit forms.

Keyboard Events

<input dj-keydown.enter="submit_search" dj-keydown.escape="clear_search" />

Mouse Enter / Leave

<!-- Fires when the pointer enters the element -->
<div dj-mouseenter="preview">Hover me</div>

<!-- Fires when the pointer leaves the element -->
<div dj-mouseenter="preview" dj-mouseleave="hide_preview">Hover me</div>

<!-- data-* attributes and inline args work like every other directive -->
<div dj-mouseenter="preview({{ item.id }})" data-item-id="{{ item.id }}">…</div>

mouseenter and mouseleave do not bubble: moving the pointer from an element into one of its children fires neither the element's dj-mouseleave nor a second dj-mouseenter — entering a child is not leaving the parent. (This is what distinguishes them from mouseover / mouseout, which fire on every descendant crossing.) Because they do not bubble, djust attaches these listeners directly to the declaring element rather than delegating on the root; dj-debounce / dj-throttle and dj-confirm are honoured as on other directives.

Window & Document Events

Bind event listeners on window or document instead of the element itself. The declaring element provides context (component ID, dj-value-* params) but the listener is attached to the global target.

<!-- Close modal on Escape (anywhere on page) -->
<div dj-window-keydown.escape="close_modal">

<!-- Track scroll position -->
<div dj-window-scroll="on_scroll" dj-value-section="hero">

<!-- Detect clicks anywhere on page -->
<div dj-document-click="on_background_click">

<!-- Handle window resize -->
<div dj-window-resize="on_resize">

Supported attributes:

AttributeTargetEvent
dj-window-keydownwindowkeydown
dj-window-keyupwindowkeyup
dj-window-scrollwindowscroll
dj-window-clickwindowclick
dj-window-resizewindowresize
dj-document-keydowndocumentkeydown
dj-document-keyupdocumentkeyup
dj-document-clickdocumentclick

Key modifier filtering works the same as dj-keydown: dj-window-keydown.escape="close".

dj-window-scroll and dj-window-resize fire on every browser event. They are not throttled by default, and they ignore dj-throttle/data-throttle on the element. Scroll handlers receive scrollX/scrollY; resize handlers receive innerWidth/innerHeight. Rate-limit them with the handler decorator:

from djust.decorators import event_handler, throttle

@event_handler()
@throttle(interval=0.15)
def on_scroll(self, scrollY: int = 0, **kwargs):
    self.scroll_pos = scrollY

Click Away

Fire an event when the user clicks outside an element. Common for dropdowns, modals, and popovers:

<div dj-click-away="close_dropdown" class="dropdown-menu">
    <!-- clicking outside this div fires close_dropdown -->
</div>

Uses a capture-phase document listener, so stopPropagation() inside the element does not prevent detection. Supports dj-confirm for confirmation dialogs and dj-value-* params.

Keyboard Shortcuts (dj-shortcut)

Declarative keyboard shortcuts with modifier key support:

<!-- Single shortcut -->
<div dj-shortcut="escape:close_modal">

<!-- Multiple shortcuts on one element -->
<div dj-shortcut="ctrl+k:open_search:prevent, escape:close_modal">

<!-- Modifier keys: ctrl, alt, shift, meta (cmd on Mac) -->
<button dj-shortcut="ctrl+shift+S:save_draft:prevent">Save</button>

With shift, write a letter key as the browser reports it (S, not s): the key is compared case-sensitively against KeyboardEvent.key.

Syntax: [modifier+...]key:handler[:prevent], comma-separated for multiple bindings.

The prevent modifier calls e.preventDefault() to suppress browser defaults (e.g., ctrl+k normally opens the browser URL bar).

Shortcuts are automatically skipped when the user is typing in form inputs (input, textarea, select, contenteditable). Add dj-shortcut-in-input to force shortcuts even in inputs.

Handler receives key, code, and shortcut (the matched binding string, e.g., "ctrl+k") as event params, along with any dj-value-* attributes.

Form Field Targeting (_target)

When multiple form fields share one handler (e.g., dj-change="validate"), the _target parameter tells the server which field triggered the event. This enables efficient per-field validation without needing a separate handler per field.

<form>
    <input name="email" dj-change="validate" />
    <input name="username" dj-change="validate" />
</form>
@event_handler()
def validate(self, value: str = "", _target: str = "", **kwargs):
    if _target == "email":
        self.email_error = "" if "@" in value else "Invalid email"
    elif _target == "username":
        self.username_error = "" if len(value) >= 3 else "Too short"

_target is the triggering element's name attribute (falling back to id, then null). It is included automatically in dj-change, dj-input, and dj-submit (submitter button name) events. Matches Phoenix LiveView's _target convention.

_target is sent to legacy-policy handlers only. A strict handler uses field or an explicit dj-value-* argument instead.

Preventing Double Submits

dj-disable-with

Automatically disable a submit button and replace its text during form submission:

<form dj-submit="save">
    {% csrf_token %}
    <input name="title" value="{{ title }}" />
    <button type="submit" dj-disable-with="Saving...">Save</button>
</form>

While the server processes the event, the button shows "Saving..." and is disabled. After the server responds, the original text and enabled state are restored. Also works with dj-click:

<button dj-click="generate" dj-disable-with="Generating...">Generate Report</button>

dj-lock

Prevent an element from firing its event again until the server responds:

<button dj-click="save" dj-lock>Save</button>

Unlike dj-disable-with (which is cosmetic feedback), dj-lock blocks the event from firing at all while a previous invocation is in flight. For form elements (button, input, select, textarea), the element is disabled. For non-form elements (e.g., <div dj-click="..." dj-lock>), a djust-locked CSS class is applied instead.

Combine both for the full pattern:

<button dj-click="save" dj-lock dj-disable-with="Saving...">Save</button>

All locked elements are unlocked when any server response arrives.

Mounted Event (dj-mounted)

Fire a server event when an element enters the DOM after a VDOM patch:

{% if show_chart %}
<div dj-mounted="on_chart_ready" dj-value-chart-type="bar">
    <canvas id="my-chart"></canvas>
</div>
{% endif %}
@event_handler()
def on_chart_ready(self, chart_type: str = "", **kwargs):
    self.chart_data = load_chart_data(chart_type)

Key behavior:

  • Only fires after VDOM patches (not on initial page load)
  • Includes dj-value-* attributes from the mounted element as event params
  • Uses a WeakSet internally to prevent duplicate fires for the same DOM node
  • Handlers should be idempotent (if the element is replaced by a patch, it fires again for the new node)

Use cases: trigger data loading when a tab becomes active, initialize third-party widgets, scroll new elements into view, animate elements on appearance.

Rate Limiting

HTML Attributes (dj-debounce / dj-throttle)

Apply debounce or throttle to any dj-* event attribute directly in HTML, giving per-element control without changing the Python handler:

<!-- Debounce: wait 300ms after last keystroke before firing -->
<input dj-input="search" dj-debounce="300" />

<!-- Throttle: fire at most every 500ms -->
<button dj-click="poll_status" dj-throttle="500">Refresh</button>

<!-- Disable default debounce on dj-input (fires immediately) -->
<input dj-input="on_change" dj-debounce="0" />

<!-- Defer until blur (Phoenix parity) -->
<input dj-input="validate" dj-debounce="blur" />

dj-debounce and dj-throttle work with all event types: dj-click, dj-change, dj-input, dj-keydown, dj-keyup. Each element gets its own independent timer. data-debounce/data-throttle are legacy spellings honoured only on dj-input; use dj-debounce/dj-throttle everywhere.

Python Decorators

Use decorators to rate-limit a handler for every element that triggers it (applied in the browser before the event is sent):

from djust.decorators import event_handler, debounce, throttle

# Wait 500ms after the last keystroke before firing
@event_handler()
@debounce(wait=0.5)
def search(self, value: str = "", **kwargs):
    self.query = value
    self._refresh()

# Fire at most once per second
@event_handler()
@throttle(interval=1.0)
def on_scroll(self, scrollY: int = 0, **kwargs):
    self.scroll_pos = scrollY

HTML attributes and Python decorators can be combined: the HTML attribute wraps one element's listener, and the decorator gates every send of that handler. Both run client-side and compose.

Loading States

Show feedback while a handler is running — use the dj-loading.* attributes (there is no loading decorator in djust.decorators; the client manages the pending state automatically while the event is in flight):

<button dj-click="save" dj-loading.disable>
    <span dj-loading.hide dj-loading.for="save">Save</span>
    <span dj-loading.show dj-loading.for="save" style="display:none">Saving…</span>
</button>

For long-running operations (API calls, AI generation), use the @background decorator to run work in a background thread. See Loading States & Background Work for details.

DOM Keying for Lists

When rendering lists that can reorder, add data-key for optimal VDOM diffing:

{% for item in items %}
<div data-key="{{ item.id }}">
    {{ item.name }}
    <button dj-click="delete" data-item-id="{{ item.id }}">Delete</button>
</div>
{% endfor %}

Without data-key, djust diffs by position and may produce unnecessary DOM mutations.

Ignoring DOM Subtrees

If a subtree is managed by external JavaScript (charts, editors), prevent djust from patching it:

<div dj-update="ignore" id="my-chart"></div>

UI Feedback Attributes

Connection State CSS Classes

djust automatically applies CSS classes to <body> based on transport state:

  • dj-connected — WebSocket/SSE connection is open
  • dj-disconnected — WebSocket/SSE connection is lost

Both classes are removed on intentional disconnect (TurboNav navigation). Use these for CSS-driven connection indicators:

body.dj-disconnected [dj-root] { opacity: 0.5; }
.offline-banner { display: none; }
body.dj-disconnected .offline-banner { display: block; }

dj-cloak (FOUC Prevention)

Hide elements until the WebSocket/SSE mount completes:

<div dj-cloak>
    <button dj-click="increment">+</button>
</div>

The CSS rule is injected automatically by client.js. The dj-cloak attribute is removed when the mount response arrives.

dj-scroll-into-view

Auto-scroll an element into view after it appears in the DOM (mount or VDOM patch):

<div dj-scroll-into-view>New chat message</div>
<div dj-scroll-into-view="instant">Alert</div>
<div dj-scroll-into-view="center">Highlighted item</div>

One-shot per DOM node. Supports values: "" (smooth/nearest), "instant", "center", "start", "end".

Page Loading Bar

An NProgress-style loading bar appears automatically during TurboNav and live_redirect navigation. Control via window.djust.pageLoading.start() / .finish() or disable with window.djust.pageLoading.enabled = false.

Navigation lifecycle events are dispatched during dj-navigate transitions:

  • djust:navigate-start — fires when navigation begins
  • djust:navigate-end — fires when the new page renders

The .djust-navigating CSS class is added to [dj-root] during navigation, enabling CSS-only page transitions:

[dj-root].djust-navigating main {
    opacity: 0.3;
    transition: opacity 0.15s ease;
    pointer-events: none;
}

For advanced use cases, listen for the events in JS:

document.addEventListener('djust:navigate-start', () => showSkeleton());
document.addEventListener('djust:navigate-end', () => hideSkeleton());

Clipboard Copy (dj-copy)

Copy text to the clipboard on click without a server round-trip:

<!-- Copy literal text -->
<button dj-copy="{{ share_url }}">Copy Link</button>

<!-- Copy text content from another element -->
<button dj-copy="#code-block">Copy Code</button>

<!-- Custom feedback text (default: "Copied!") -->
<button dj-copy="{{ api_key }}" dj-copy-feedback="Done!">Copy Key</button>

<!-- Custom CSS class feedback (adds class for 2 seconds) -->
<button dj-copy="{{ token }}" dj-copy-class="btn-success">Copy</button>

<!-- Fire a server event after successful copy (e.g., for analytics) -->
<!-- The handler receives one param, text (the copied string) -->
<button dj-copy="#snippet" dj-copy-event="copied">Copy</button>
AttributeDescription
dj-copy="text"Literal text to copy
dj-copy="#selector"Copy textContent of the matched element
dj-copy-feedback="text"Button text shown for 1.5s after copy (default: "Copied!")
dj-copy-class="class"CSS class added for 2s after copy (default: dj-copied)
dj-copy-event="handler"Server event fired after successful copy; receives text

All enhancements are backward compatible with existing dj-copy usage.

Reconnection Recovery (dj-auto-recover)

After a WebSocket reconnect, elements with dj-auto-recover automatically fire a server event with serialized DOM state, enabling the server to restore state that the default form-value replay cannot:

<div dj-auto-recover="restore_canvas" data-canvas-id="main">
    <canvas id="drawing-canvas"></canvas>
    <input name="brush_size" value="5" />
</div>
@event_handler()
def restore_canvas(self, _form_values=None, _data_attrs=None, **kwargs):
    """Called automatically after reconnect with DOM state from the container."""
    form = _form_values or {}
    data = _data_attrs or {}
    self.brush_size = int(form.get("brush_size") or 5)
    self.canvas_id = data.get("canvas-id", "")

Key behavior:

  • Does not fire on initial page load — only after reconnection
  • Serializes the container's form field values and data-* attributes; they arrive as the _form_values and _data_attrs dicts. data-* keys keep their dashes (canvas-id), and dj-value-* attributes are not included
  • Multiple independent dj-auto-recover elements can coexist on the same page
  • Use for complex state (drag positions, canvas state, multi-step wizard progress) that form replay cannot restore

Data Attribute Naming Convention

When you add data-* attributes to an element with a dj-click (or any dj-* event), djust automatically extracts them and passes them as keyword arguments to your Python handler. The conversion follows two rules:

  1. The data- prefix is stripped — data-item-id becomes item-id
  2. Dashes become underscores — item-id becomes item_id

So data-item-id="5" arrives in your handler as item_id="5".

Basic example

<button dj-click="delete" data-item-id="{{ item.id }}" data-category="{{ item.category }}">
    Delete
</button>
@event_handler()
def delete(self, item_id: int = 0, category: str = "", **kwargs):
    # data-item-id="5"      → item_id=5   (coerced to int via type hint)
    # data-category="draft"  → category="draft"
    self.items = [i for i in self.items if i["id"] != item_id]

Type coercion

All data-* values are strings in HTML. djust provides two ways to coerce them:

Python type hints (server-side) — add a type annotation to the handler parameter. djust inspects the annotation and coerces the string value automatically:

@event_handler()
def update(self, count: int = 0, price: float = 0.0, enabled: bool = False, **kwargs):
    # data-count="42"     → count=42
    # data-price="19.99"  → price=19.99
    # data-enabled="true" → enabled=True
    pass

Supported Python types: int, float, bool, str, list, List[T], Optional[T].

Type-hint suffixes (client-side) — append a colon and type to the attribute name to coerce before sending to the server:

<button dj-click="update"
        data-count:int="42"
        data-price:float="19.99"
        data-enabled:bool="true"
        data-tags:json='["a", "b"]'
        data-items:list="a,b,c">
    Update
</button>
SuffixConverts toExample
:int / :integerNumber (integer)data-count:int="42" → 42
:float / :numberNumber (float)data-price:float="19.99" → 19.99
:bool / :booleanBooleandata-active:bool="true" → true
:json / :object / :arrayParsed JSONdata-config:json='{"a":1}' → {"a": 1}
:listComma-separated arraydata-tags:list="a,b,c" → ["a", "b", "c"]

Both approaches work together: client-side suffixes convert before sending, and Python type hints coerce on arrival. If you use both, the Python coercion acts on the already-converted value.

The dj-value-* alternative

dj-value-* attributes work the same way as data-* but take precedence when both are present. They match Phoenix LiveView's phx-value-* convention:

<button dj-click="select" dj-value-item-id:int="{{ item.id }}">
    Select
</button>

dj-value-item-id → item_id (same dash-to-underscore rule). The dj-value-* form is preferred for event parameters because it avoids collisions with third-party libraries that also use data-* attributes.

Internal attributes are excluded

djust skips its own internal data-* attributes so they don't leak into your handler kwargs:

  • data-liveview-*, data-live-*, data-djust-* — framework internals
  • data-loading, data-component-id — component machinery

data-key is not skipped. If the keyed element itself carries a dj-* event, the handler receives key=, so accept **kwargs or use dj-key (also a VDOM list diffing key) instead.

Quick reference

HTML attributePython kwargNotes
data-item-id="5"item_id="5"Dashes → underscores
data-item-id:int="5"item_id=5Client-side coercion
data-user-name="Jo"user_name="Jo"Multi-word names
data-x="1"x="1"Single-char names work
data-dj-preset="dark"preset="dark"dj_ prefix stripped
dj-value-section="hero"section="hero"dj-value-* form

Typed event parameters (strict policy)

Under the strict parameter policy, the handler's Python signature is the event's contract. Named parameters, their annotations and their defaults decide what an event may carry. Values are converted exactly, and an event that doesn't fit is rejected before your code runs. Legacy remains the default. Opt in per handler, or for the whole project:

LIVEVIEW_CONFIG = {"event_parameter_policy": "strict"}

@event_handler(parameter_policy="strict") opts one handler in, and parameter_policy="legacy" opts one out of a strict project.

Example: typed click arguments

from djust import LiveView
from djust.decorators import event_handler


class ItemSelectionView(LiveView):
    template_name = "items/select.html"

    def mount(self, request, **kwargs):
        self.selected_id = 0
        self.active = False

    @event_handler(parameter_policy="strict")
    def select_item(self, item_id: int, active: bool = False) -> None:
        # A demonstration allowlist, not a substitute for database authorization.
        if item_id not in {42, 87}:
            raise ValueError("Unknown item")
        self.selected_id = item_id
        self.active = active
<button type="button" dj-click="select_item"
        dj-value-item-id="42" dj-value-active="true">Select item</button>

dj-value-item-id arrives as item_id. No :int suffix is needed, because the annotation converts "42" to 42. item_id is required; active keeps its default when the attribute is absent. A converted ID is not an authorized object: look the record up and check permissions as usual.

Example: inputs and forms

from djust import LiveView
from djust.decorators import event_handler


class NoteView(LiveView):
    template_name = "notes/edit.html"

    def mount(self, request, **kwargs):
        self.query = ""
        self.saved = {}

    @event_handler(parameter_policy="strict")
    def search(self, value: str) -> None:
        self.query = value

    @event_handler(parameter_policy="strict")
    def save(self, title: str, **fields: str) -> None:
        self.saved = {"title": title, **fields}
<input name="q" dj-input="search">
<form dj-submit="save">
    <input name="title" value="Draft">
    <input name="notes" value="Hello">
    <button type="submit">Save</button>
</form>

The browser sends a generated value only when the handler declares a parameter with that name, or has a ** catch-all:

  • value and field for dj-input and dj-change;
  • the form's fields for dj-submit;
  • key and code for keyboard events.

search(value) therefore receives just value, and save receives every field through **fields. dj-value-* arguments are always sent, and one that reuses a generated name is rejected. _target is never sent. Only dj-value-* names become arguments: data-* attributes and dj-params are ignored for strict handlers.

Conversion rules

AnnotationAccepts
strText, unchanged (no trimming)
intAn integer, or complete decimal-integer text. Not true, blank text, 4.2, 1_000 or 12abc
floatA finite number or numeric text. Not a bool, NaN or infinity
booltrue/false, 1/0, yes/no, on/off (any case), or a JSON bool
DecimalA decimal string or an integer. Not a binary float
UUIDA valid UUID string
dateISO YYYY-MM-DD only
Optional[T]JSON null or a valid T. The argument is still required unless it has a default
list[T]A JSON array whose members are valid T. Not comma-separated text
AnyAnything, unchecked: an explicit escape hatch

Other annotations, and named parameters without one, are rejected at startup (check djust.V016). coerce_types=False keeps validation but turns conversion off, so only already-typed values pass. Positional-only and keyword-only parameters keep their Python meaning: dj-click="choose(3)" fills the leading positional parameters, and the same value given by name as well is an error.

What a rejection looks like

The browser checks strict arguments before sending: malformed typed literals such as dj-value-n:int="12abc", wire hints that contradict the annotation, and duplicate names. On a failure it applies no loading, disable or optimistic effect. It logs a fixed message and dispatches djust:error, which the DEBUG overlay shows. The server validates every event again, including hand-crafted messages. It rejects missing, extra, duplicate and invalid arguments without calling the handler, and its error names the parameter and expected type, never the submitted value.

Framework context and exceptions

  • Routing keys (view_id, component_id) and client bookkeeping are never handler arguments. Names starting with _ are reserved (check V016).
  • dj-auto-recover handlers always run under the legacy policy (check V019 warns about a strict declaration).
  • dj-model sends the fixed field/value pair its handler declares.

Migrating a handler

  1. Annotate every named parameter, removing defaults that only existed to hide missing input.
  2. Replace data-* and dj-params arguments with dj-value-*, and _target with field or a dj-value-* argument.
  3. Add parameter_policy="strict", drop **kwargs unless the handler really takes an open payload, and run manage.py check: C022 and V016–V019 report what strict dispatch would reject.

Next Steps