Loading States & Background Work
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
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:
- 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.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
- User clicks "Generate Report"
generate_report()setsself.generating = Trueand callsself.start_async(self._do_generate)- The WebSocket consumer sends VDOM patches immediately (spinner appears)
- The response includes
async_pending: true, telling the client to keep loading state active - The consumer spawns
_do_generate()in an asyncio background task - When
_do_generate()returns, the view re-renders and sends updated patches - 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
@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")
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:
| Phase | dj-loading.* active? | Why |
|---|---|---|
| Event sent to server | Yes | Client starts loading on event fire |
Server responds with patches + async_pending: true | Yes | Client keeps loading active |
| Background work completes, server sends final patches | No | No 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:
| 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.