---
title: "Loading States & Background Work"
slug: loading-states
section: guides
order: 5
level: intermediate
description: "Show spinners, disable buttons, and run slow work in the background with immediate UI feedback"
---

# 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.

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

### Available Modifiers

| Attribute | Effect | Example |
|-----------|--------|---------|
| `dj-loading.disable` | Sets `disabled=true` during loading | `<button dj-click="save" dj-loading.disable>` |
| `dj-loading.show` | Shows 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.hide` | Hides 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.

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

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

```python
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).

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

```python
@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:

```python
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:

```python
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`:

```python
@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:

```python
@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:

```python
@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")
```

To cancel all work owned by the current view, use `self.cancel_async_all()`.
It removes queued `start_async()` / `@background` callbacks and requests
cancellation of callbacks dispatched by the shared runtime or WebSocket consumer.
Their stale completion handlers and renders are suppressed, including when a
coroutine catches cancellation and returns a value.

Cancellation is cooperative: already-running synchronous code in a worker thread
cannot be forcibly interrupted. Its side effects may still finish; cancellation
does not roll them back. Independently created application tasks are not tracked
by this API. Reset any application loading flags in your cancelling event handler.

### Handling Completion or Errors

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

```python
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

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

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

```html
<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](streaming.md) helpers. Both `AsyncWorkMixin` and `StreamingMixin` are already built into `LiveView`, so just subclass `LiveView` — do not inherit them explicitly (it raises an MRO `TypeError`):

```python
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:

```javascript
// 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.

```python
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:

| Flag | Meaning |
|------|---------|
| `.loading` | Work scheduled / in flight |
| `.ok` | Completed successfully; `.result` holds the payload |
| `.failed` | Raised; `.error` holds the exception |

Templates read the flags directly:

```html
{% 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](declarative-ux-attrs.md#-djust_skeleton--template-tag)
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>"`:

```python
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:

```html
{% 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:

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