djust docs
Browse documentation

Loading States & Background Work

Show spinners, disable buttons, and run slow work in the background with immediate UI feedback

On this page

djust provides two complementary systems for responsive UIs during slow operations:

  1. Loading directives (dj-loading.*) -- Client-side attributes that show/hide/disable elements while an event is in flight
  2. AsyncWorkMixin (start_async()) -- Server-side mixin that flushes UI state immediately, then runs slow work in a background thread

Together they let you show a spinner instantly, run a 10-second API call, and update the UI when it finishes -- all without writing any JavaScript.

Loading Directives

Basic Usage

Add dj-loading.* attributes to any element that has a dj-click, dj-submit, or other event attribute. The loading state activates when the event fires and deactivates when the server responds.

<button dj-click="save" dj-loading.disable>
    Save
</button>

Available Modifiers

AttributeEffectExample
dj-loading.disableSets disabled=true during loading<button dj-click="save" dj-loading.disable>
dj-loading.showShows the element during loading (hidden otherwise)<div dj-loading.show style="display:none">Saving...</div>
dj-loading.show="flex"Shows with specific display value<div dj-loading.show="flex" style="display:none">
dj-loading.hideHides the element during loading<span dj-loading.hide>Ready</span>
dj-loading.class="name"Adds a CSS class during loading<div dj-loading.class="opacity-50">

Scoping with dj-loading.for

By default, loading modifiers are scoped to the event on the same element. Use dj-loading.for to tie any element's loading state to a specific event name, regardless of where that element is in the DOM.

<!-- Button triggers the event -->
<button dj-click="generate_report">Generate</button>

<!-- Spinner anywhere in the page, tied to the same event -->
<div dj-loading.show dj-loading.for="generate_report" style="display:none">
    <span class="spinner"></span> Generating report...
</div>

<!-- Disable another button while report generates -->
<button dj-click="export" dj-loading.disable dj-loading.for="generate_report">
    Export
</button>

This is useful when:

  • The loading indicator is far from the trigger button in the DOM
  • Multiple elements should react to the same event
  • You want to disable unrelated buttons during a long operation

dj-loading.disable on a button also disables it during its own dj-click event, in addition to the event named by dj-loading.for. In the example above, Export is disabled while generate_report runs and while its own export event is in flight.

CSS Classes

Every trigger element automatically gets the djust-loading class during loading. The <body> also gets djust-global-loading. Use these for custom CSS:

.djust-loading {
    cursor: wait;
    opacity: 0.7;
}

.djust-global-loading .sidebar {
    pointer-events: none;
}

Background Work with start_async()

The Problem

When an event handler does slow work (API calls, AI generation, file processing), the user sees nothing until the server responds -- which could be 5-30 seconds.

The Solution

AsyncWorkMixin lets you split the work into two phases:

  1. Immediate response -- Update state (e.g., self.generating = True), flush to client
  2. Background work -- Run the slow operation in a thread; when done, re-render automatically
from djust import LiveView
from djust.decorators import event_handler


# AsyncWorkMixin is already included in LiveView — just subclass LiveView.
# (Inheriting it explicitly, e.g. `class ReportView(AsyncWorkMixin, LiveView)`, raises
#  TypeError: Cannot create a consistent method resolution.)
class ReportView(LiveView):
    template_name = "report.html"

    def mount(self, request, **kwargs):
        self.generating = False
        self.report_html = ""
        self.error = ""

    @event_handler()
    def generate_report(self, **kwargs):
        self.generating = True        # Shows spinner immediately
        self.error = ""
        self.start_async(self._do_generate)

    def _do_generate(self):
        """Runs in a background thread after the client sees the spinner."""
        try:
            self.report_html = call_slow_api()  # 10 seconds
        except Exception as e:
            self.error = str(e)
        self.generating = False
        # View automatically re-renders when this returns

Template

Drive the spinner from server state (generating), not from dj-loading.*. The dj-loading.* directives stop at the first server response, which arrives before the background work finishes (see the known issue below).

<button dj-click="generate_report" {% if generating %}disabled{% endif %}>
    Generate Report
</button>

<!-- Spinner: visible until _do_generate() sets generating = False -->
{% if generating %}
<div>
    <span class="spinner-border spinner-border-sm"></span>
    Generating...
</div>
{% endif %}

{% if report_html %}
<div class="report">
    {{ report_html|safe }}
</div>
{% endif %}

{% if error %}
<div class="alert alert-danger">{{ error }}</div>
{% endif %}

How It Works Under the Hood

  1. User clicks "Generate Report"
  2. generate_report() sets self.generating = True and calls self.start_async(self._do_generate)
  3. The WebSocket consumer sends VDOM patches immediately (generating is now True, so the spinner appears)
  4. The consumer spawns _do_generate() in an asyncio background task
  5. When _do_generate() returns, the view re-renders and sends updated patches (generating is False, so the spinner disappears)

The first response carries async_pending: true whenever the handler queued background work with start_async(), @background or assign_async(), so dj-loading.* states on the triggering element stay active until the work's result is rendered.

Passing Arguments

start_async() forwards positional and keyword arguments to the callback:

@event_handler()
def start_export(self, **kwargs):
    fmt = kwargs.get("format", "csv")
    self.exporting = True
    self.start_async(self._run_export, format=fmt)

def _run_export(self, format="csv"):
    self.data = expensive_export(format)
    self.exporting = False

Error Handling

If the background callback raises an exception:

  • The exception is logged (not sent to the client)
  • The view re-renders its current state, which ends any dj-loading.* state the event started. Unless handle_async_result() resets it, state set before the failure (such as generating = True) stays on screen

To handle errors gracefully, catch exceptions in your callback and set error state:

def _do_work(self):
    try:
        self.result = risky_operation()
    except Exception as e:
        self.error = f"Operation failed: {e}"
    finally:
        self.loading = False  # Always clear loading state

The @background Decorator

For simpler syntax, use the @background decorator to automatically run the entire handler in the background:

from djust import LiveView
from djust.decorators import event_handler, background


class ContentView(LiveView):
    template_name = "content.html"

    def mount(self, request, **kwargs):
        self.generating = False
        self.content = ""
        self.error = ""

    @event_handler
    @background
    def generate_content(self, prompt: str = "", **kwargs):
        """Entire method runs in background thread."""
        self.generating = True
        try:
            self.content = call_llm(prompt)  # Slow operation
        except Exception as e:
            self.error = str(e)
        finally:
            self.generating = False

The @background decorator:

  • Automatically wraps the handler to call start_async() internally
  • Uses the function name as the task name (for cancellation/tracking)
  • Can be combined with other decorators like @debounce:
@event_handler
@debounce(wait=0.5)
@background
def auto_save(self, **kwargs):
    # Debounced and runs in background
    self.save_draft()

When to use @background vs start_async():

  • Use @background when the entire handler should run in the background
  • Use start_async() when you need to update state before starting background work, or when you need multiple concurrent async tasks with different names

Task Naming and Cancellation

Both start_async() and @background support named tasks for tracking and cancellation:

@event_handler
def start_export(self, **kwargs):
    self.exporting = True
    self.start_async(self._run_export, format="csv", name="export")

def _run_export(self, format="csv"):
    self.data = expensive_export(format)
    self.exporting = False

@event_handler
def cancel_export(self, **kwargs):
    self.cancel_async("export")  # Cancel the named task
    self.exporting = False
    self.status = "Cancelled"

With @background, the task name is automatically set to the handler's function name:

@event_handler
@background
def generate_report(self, **kwargs):
    # Task name is "generate_report"
    ...

@event_handler
def cancel_report(self, **kwargs):
    self.cancel_async("generate_report")

Handling Completion or Errors

Implement handle_async_result() to receive notifications when async tasks complete or fail:

def handle_async_result(self, name: str, result=None, error=None):
    """Called when any async task completes."""
    if error:
        self.error_message = f"Task {name} failed: {error}"
        self.loading = False
    elif name == "export":
        self.status = "Export complete"

This method is optional. If it is not implemented, tasks still re-render when they finish. A failed task is logged and re-renders the state as it stands, so state set before the failure stays on screen.

Combining Both Systems

dj-loading.* directives cover the round-trip of an event: they start when the event is sent and stop when the server responds. When the handler queued background work, the response carries an async_pending flag and the directives stay on until the work's result arrives.

Use the two together like this:

  • dj-loading.* for the immediate feedback while the event is in flight
  • a server-side flag (self.generating = True before start_async(), False at the end of the callback) with {% if generating %} in the template for the background phase

Common Patterns

Disable Multiple Buttons

<button dj-click="save" dj-loading.disable>Save</button>
<button dj-click="cancel" dj-loading.disable dj-loading.for="save">Cancel</button>

Swap Button Text

For simple text replacement on submit buttons, use dj-disable-with instead of loading directives:

<!-- Simple: dj-disable-with (one attribute, automatic) -->
<button type="submit" dj-disable-with="Deploying...">Deploy</button>

<!-- Advanced: loading directives (more control over layout) -->
<button dj-click="deploy">
    <span dj-loading.hide dj-loading.for="deploy">Deploy</span>
    <span dj-loading.show dj-loading.for="deploy" style="display:none">
        Deploying...
    </span>
</button>

dj-disable-with is the simpler choice for most cases. Use loading directives when you need more complex layouts (spinners, icons, multiple elements reacting to the same event).

Full-Page Overlay

<div dj-loading.show dj-loading.for="generate"
     style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.3); z-index:999">
    <div style="position:absolute; top:50%; left:50%; transform:translate(-50%,-50%)">
        Loading...
    </div>
</div>

Progress with Streaming

For operations where you want incremental progress (not just a spinner), combine start_async() with the streaming helpers. Both AsyncWorkMixin and StreamingMixin are already built into LiveView, so just subclass LiveView — do not inherit them explicitly (it raises an MRO TypeError):

from asgiref.sync import sync_to_async


class ImportView(LiveView):  # AsyncWorkMixin + StreamingMixin are built in
    @event_handler()
    def start_import(self, **kwargs):
        self.importing = True
        self.start_async(self._do_import)

    async def _do_import(self):
        # stream_text() is a coroutine, so the callback must be async and await it.
        # (Calling it from a sync callback only creates an un-awaited coroutine.)
        for i, row in enumerate(large_dataset):
            await sync_to_async(process)(row)
            if i % 100 == 0:
                await self.stream_text("progress", f"{i} rows processed...")
        self.importing = False

Programmatic API

The loading manager is exposed globally for advanced use cases:

// Start/stop loading manually
window.djust.globalLoadingManager.startLoading('my_event');
window.djust.globalLoadingManager.stopLoading('my_event');

// Check if an event is currently loading
window.djust.globalLoadingManager.pendingEvents.has('my_event');

High-level async loading: assign_async + AsyncResult (v0.5.0)

For the common "load slow data, show a skeleton, then render the result" pattern, djust ships a high-level API that captures all three states in a single immutable value object.

from djust import LiveView

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

    def mount(self, request, **kwargs):
        # Each call schedules a concurrent loader.
        self.assign_async("metrics", self._load_metrics)
        self.assign_async("notifications", self._load_notifications)

    def _load_metrics(self):
        return expensive_query()

    async def _load_notifications(self):
        return await fetch_notifications()

assign_async(name, loader, *args, **kwargs) sets self.<name> to an AsyncResult in the loading state immediately, so the first render sends a skeleton to the client. The loader runs in the background (sync callables via sync_to_async; async def callables on the event loop). On completion the attribute is replaced with AsyncResult.succeeded(result) or AsyncResult.errored(exc) and the view re-renders.

Each AsyncResult exposes three mutually-exclusive booleans:

FlagMeaning
.loadingWork scheduled / in flight
.okCompleted successfully; .result holds the payload
.failedRaised; .error holds the exception

Templates read the flags directly:

{% load live_tags %}
{% if metrics.loading %}
  {% djust_skeleton shape="rect" width="100%" height="120px" %}
{% endif %}
{% if metrics.ok %}
  <div class="metric-card">{{ metrics.result.total_users }}</div>
{% endif %}
{% if metrics.failed %}
  <div class="error">Failed to load: {{ metrics.error }}</div>
{% endif %}

The {% djust_skeleton %} template tag (v0.6.0) emits a shimmer placeholder with no bespoke CSS required — see the declarative UX attributes guide for the full argument list. You can still hand-roll your own <div class="skeleton-card"> styles if you prefer bespoke shapes.

AsyncResult is also truthy only when .ok, so {% if metrics %}… is shorthand for "loaded successfully".

Cancellation piggybacks on the existing cancel_async API — scheduled tasks use the name "assign_async:<name>":

self.cancel_async("assign_async:metrics")

Declarative loading boundaries: {% dj_suspense %} (v0.5.0)

Scattering {% if x.loading %} / {% if x.ok %} conditionals across every section that depends on async data becomes verbose quickly. {% dj_suspense %} collapses it into one declarative wrapper:

{% dj_suspense await="metrics" fallback="components/metric_skeleton.html" %}
  <div class="metric">{{ metrics.result.total_users }}</div>
{% enddj_suspense %}

Semantics:

  • Any awaited ref is loading (or missing from context) → the fallback template is rendered. If fallback= is omitted, a minimal default spinner <div class="djust-suspense-fallback">…</div> is emitted instead.
  • Any awaited ref is failed → an error <div class="djust-suspense-error"> is rendered with the exception message (HTML-escaped).
  • All awaited refs are ok → the body is rendered verbatim.
  • No await= argument → body passes through unchanged.

Multiple references load independently and compose cleanly:

<div class="dashboard">
  {% dj_suspense await="metrics" fallback="components/metric_skeleton.html" %}
    <div class="metric-card">{{ metrics.result.total_users }}</div>
  {% enddj_suspense %}

  {% dj_suspense await="chart_data" fallback="components/chart_skeleton.html" %}
    <canvas dj-hook="Chart" data-values="{{ chart_data.result }}"></canvas>
  {% enddj_suspense %}
</div>

A slow query in one suspense boundary never blocks another. Nested {% dj_suspense %} works — each boundary resolves its own await= list against the context independently.

Design note: await="..." is required rather than inferred — keeping the tag explicit makes template-level debugging trivial. You can await multiple refs with a comma-separated list (await="metrics,chart_data,user_prefs"), and whitespace around the commas is tolerated.