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:
- Loading directives (
dj-loading.*) -- Client-side attributes that show/hide/disable elements while an event is in flight - 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
| 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.
<!-- 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:
- Immediate response -- Update state (e.g.,
self.generating = True), flush to client - 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
- User clicks "Generate Report"
generate_report()setsself.generating = Trueand callsself.start_async(self._do_generate)- The WebSocket consumer sends VDOM patches immediately (
generatingis nowTrue, so the spinner appears) - The consumer spawns
_do_generate()in an asyncio background task - When
_do_generate()returns, the view re-renders and sends updated patches (generatingisFalse, 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. Unlesshandle_async_result()resets it, state set before the failure (such asgenerating = 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
@backgroundwhen 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")
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:
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 = Truebeforestart_async(),Falseat 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:
| 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:
{% 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) → thefallbacktemplate is rendered. Iffallback=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.