Skip to content
djust
Appearance
Mode

Loading States & Background Work

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

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;
}

Configuring Grouping Classes

Loading state scoping uses container CSS classes to group related elements. Configure which classes act as grouping containers:

# settings.py
LIVEVIEW_CONFIG = {
    'loading_grouping_classes': [
        'd-flex',           # Bootstrap
        'flex',             # Tailwind
        'my-custom-group',  # Your own
    ],
}

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.mixins.async_work import AsyncWorkMixin
from djust.decorators import event_handler


class ReportView(AsyncWorkMixin, 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

<button dj-click="generate_report"
        dj-loading.disable
        dj-loading.for="generate_report">
    Generate Report
</button>

<!-- Spinner: visible during both the initial response AND background work -->
<div dj-loading.show dj-loading.for="generate_report"
     style="display:none">
    <span class="spinner-border spinner-border-sm"></span>
    Generating...
</div>

{% 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 (spinner appears)
  4. The response includes async_pending: true, telling the client to keep loading state active
  5. The consumer spawns _do_generate() in an asyncio background task
  6. When _do_generate() returns, the view re-renders and sends updated patches
  7. This final response does NOT have async_pending, so loading state stops (spinner disappears)

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 loading state on the client will remain active indefinitely

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 not implemented, errors are logged and the view re-renders normally when the task completes.

Combining Both Systems

The loading directives and start_async() are designed to work together. The key is the async_pending flag:

Phasedj-loading.* active?Why
Event sent to serverYesClient starts loading on event fire
Server responds with patches + async_pending: trueYesClient keeps loading active
Background work completes, server sends final patchesNoNo async_pending flag, client stops loading

Without start_async(), loading states end as soon as the server responds. With start_async(), they persist through the entire background operation.

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 StreamingMixin:

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

    def _do_import(self):
        for i, row in enumerate(large_dataset):
            process(row)
            if i % 100 == 0:
                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.