Server-Driven UI
On this page
djust gives the server full declarative control over the browser. Instead of writing JavaScript hooks that listen for clicks and mutate the DOM, you build a JS Command chain in Python and push it directly to the connected client. The framework runs it. No custom JS.
This guide covers self.push_commands(chain) — the foundation primitive for every backend-driven UI feature in djust. It's intentionally small (one method) but unlocks a large class of applications: guided tours, wizards, instructor-led workshops, remote support handoffs, AI-driven voice interfaces, automated UI testing.
The simplest possible example
from djust import LiveView
from djust.decorators import event_handler
from djust.js import JS
class DashboardView(LiveView):
template_name = "dashboard.html"
@event_handler
def highlight_new_button(self, **kwargs):
"""Fired from a 'Show me around' button on the dashboard."""
self.push_commands(
JS.add_class("tour-highlight", to="#btn-new-project")
.focus("#btn-new-project")
.transition("pulse", to="#btn-new-project", time=600)
)
<!-- dashboard.html -->
<button dj-click="highlight_new_button">Show me around</button>
<button id="btn-new-project">Create a project</button>
Click "Show me around" and the server pushes three DOM operations down the WebSocket: add a tour-highlight class to the create-project button, move keyboard focus to it, and run a CSS pulse animation. Zero custom JavaScript. Zero DOM code in the template.
How it works
Server side — push_commands(chain)
push_commands() is a one-line helper on LiveView (via PushEventMixin). It takes a JSChain and queues a djust:exec push event carrying the chain's ops list:
# What push_commands does under the hood
def push_commands(self, chain):
self.push_event("djust:exec", {"ops": chain.ops})
The chain is serialized as a JSON-safe list of [op_name, args] pairs. It piggybacks on the existing push_event transport, so there's no new wire protocol, no new WebSocket message type, and no new infrastructure to deploy.
Client side — the djust:exec auto-executor
Every djust page automatically runs a small listener (src/27-exec-listener.js) that watches for djust:exec push events and interprets them via window.djust.js._executeOps(ops, document.body) — the same function that runs inline dj-click="[[...]]" JSON chains and fluent-API .exec() calls from hook code.
You don't write a dj-hook, you don't import anything in your templates, you don't configure the auto-executor. It ships with client.js and is bound once at load time.
End-to-end flow
1. User clicks dj-click="highlight_new_button"
2. Server: highlight_new_button() runs, calls self.push_commands(chain)
3. Server: chain queued in self._pending_push_events
4. Server: normal event response is sent (VDOM patches, etc.)
5. Server: flushes push-event queue → WebSocket type:'push_event'
6. Client: 03-websocket.js dispatches djust:push_event CustomEvent on window
7. Client: 27-exec-listener.js catches it, filters for event === 'djust:exec'
8. Client: calls window.djust.js._executeOps(payload.ops, document.body)
9. Client: each op runs against the DOM (add_class, focus, transition, etc.)
Every step is inspectable in the djust debug panel (Ctrl+Shift+D), same as any other push event.
The eleven commands you can push
Every JS Command from v0.4.1 works in a pushed chain. Quick reference:
| Command | Effect |
|---|---|
JS.show(selector) | Unhide an element (clear display:none) |
JS.hide(selector) | Set display:none |
JS.toggle(selector) | Flip between shown/hidden |
JS.add_class(names, to=selector) | Add CSS classes |
JS.remove_class(names, to=selector) | Remove CSS classes |
JS.transition(names, to=selector, time=ms) | Apply classes, wait N ms, remove them (animations) |
JS.set_attr(name, value, to=selector) | Set an HTML attribute |
JS.remove_attr(name, to=selector) | Remove an HTML attribute |
JS.focus(selector) | Move keyboard focus |
JS.dispatch(event, to=selector, detail=...) | Fire a CustomEvent |
JS.push(event, value=...) | Send a server event (round-trip) |
All scoped-target options (to, inner, closest) work the same way they do in dj-click chains. See the JS Commands guide for the full reference.
Patterns
Sequencing multiple visible steps
Every call to push_commands() queues a separate djust:exec event, and the client runs them in the order they were queued:
@event_handler
def run_tour(self, **kwargs):
self.push_commands(JS.add_class("highlight", to="#step-1"))
self.push_commands(JS.add_class("highlight", to="#step-2"))
self.push_commands(JS.add_class("highlight", to="#step-3"))
Three separate events, delivered in order right after the handler returns. In a synchronous handler they arrive back-to-back, so the user sees no gap between them. For visible pacing ("highlight for 2 seconds, then advance"), use a @background handler and call await self.flush_push_events() between steps (see Background work and pushed commands), or use wait_for_event or TutorialMixin, which handle timing declaratively.
Mixing commands with state changes
Nothing special — push_commands composes with regular state mutation. Update your view attrs, optionally push commands, and the framework sends both the VDOM patch and the exec chain:
@event_handler
def open_modal(self, **kwargs):
self.modal_open = True # triggers VDOM patch
self.push_commands(
JS.focus("#modal-title") # moves focus after render
.transition("fade-in", to="#modal", time=200)
)
Both side effects happen on the same event round-trip. The VDOM patch lands first, then the exec chain, so the modal is already in the DOM when focus runs.
Composing with push_event
push_commands and push_event share the same queue and preserve ordering. Use them together when a chain needs to coexist with a regular event fired to a dj-hook:
@event_handler
def save(self, **kwargs):
self._persist()
self.push_event("flash", {"message": "Saved!", "type": "success"})
self.push_commands(
JS.add_class("just-saved", to=".save-button")
.transition("pulse", to=".save-button", time=400)
)
self.push_event("analytics", {"action": "document_saved"})
Four events queued in order — two plain, two exec chains. All delivered to the client after the handler returns.
Type safety
push_commands() rejects anything that isn't a JSChain with a clear TypeError:
self.push_commands("show('#modal')") # ❌ TypeError: expected JSChain
self.push_commands([["show", {"to": "#modal"}]]) # ❌ TypeError: expected JSChain
self.push_commands({"ops": [...]}) # ❌ TypeError: expected JSChain
self.push_commands(JS.show("#modal")) # ✓ works
The check is intentional: the framework validates the chain structure by requiring a real JSChain instance, which can only be built through the JS.* factory methods. You can't smuggle an arbitrary ops list through push_commands and bypass the chain's immutability guarantees.
When to reach for push_commands vs other primitives
| You want to... | Use |
|---|---|
| Run DOM ops on a direct user click, no server round-trip | Build the chain in the view (self.open_modal = JS.show('#modal')) and render it: dj-click="{{ open_modal }}" |
| Run DOM ops from inside a server handler after state changes | self.push_commands(JS.show('#modal')) |
Run DOM ops from a client-side dj-hook lifecycle callback | this.js().show('#modal').exec() |
| Run DOM ops in response to any server event in arbitrary code | window.djust.js.show('#modal').exec() |
| Build a guided tour with highlight + narrate + wait-for-user | TutorialMixin |
| Pause a background handler until the user acts | wait_for_event |
| Drive another user's UI (support, instructor, assist) | Consent envelope (planned) |
| Have an LLM generate UI commands from user speech | AssistantMixin (planned) |
Everything in the "planned" rows is to be built on top of push_commands. It's intentionally the smallest possible primitive so every higher-level feature composes cleanly.
Background work and pushed commands
push_commands works inside @background handlers too, which is the pattern for any flow longer than a single click.
Inside a @background task, pushed commands are queued until the task returns. Call await self.flush_push_events() after each step to deliver it immediately:
import asyncio
from djust import LiveView
from djust.decorators import event_handler, background
from djust.js import JS
class Onboarding(LiveView):
@event_handler
@background
async def start_tour(self, **kwargs):
self.tour_running = True
# Step 1: highlight dashboard nav
self.push_commands(
JS.add_class("tour-highlight", to="#nav-dashboard")
.dispatch("tour:narrate", detail={"text": "This is your dashboard."})
)
await self.flush_push_events() # the client sees step 1 now
await asyncio.sleep(3)
self.push_commands(JS.remove_class("tour-highlight", to="#nav-dashboard"))
# Step 2: highlight create button
self.push_commands(
JS.add_class("tour-highlight", to="#btn-new-project")
.dispatch("tour:narrate", detail={"text": "Click here to start a project."})
)
await self.flush_push_events()
# ... and so on
self.tour_running = False
Each step is flushed, the user sees the highlight appear, the task waits, the highlight disappears, and the next one lands. Without the flush_push_events() calls, every step would arrive at once when the task ends. asyncio.sleep(3) is the simplest possible "wait". For "wait for the user to actually click the highlighted button" behavior, use wait_for_event.
TutorialMixin makes all of this declarative — a list of TutorialStep entries — with no manual state machine to write.
Debugging
Open the djust debug panel (Ctrl+Shift+D) and switch to the Network tab. Every djust:exec push event shows up alongside regular push events and VDOM patches, with the full ops payload visible when you click on the entry. If a chain isn't doing what you expect:
- Is the event in the Network tab? If not,
push_commandswasn't called — check the server-side handler path. - Is the
opspayload shaped correctly? Each entry should be a[op_name, args_dict]pair. - Is the target selector matching any elements? Try it in the browser console:
document.querySelectorAll('#your-selector'). - Is
window.djust.jsloaded? Runtypeof window.djust.js._executeOpsin the console — should return'function'. - Set
window.djustDebug = truein the console and re-run the handler — the auto-executor will log any op failures.
Most "chain didn't do anything" issues are selector mismatches or push_commands not being called. The auto-executor itself is small enough (~40 lines of source) that it rarely causes problems.
What's next
push_commands is Phase 1a of the backend-driven UI story in ADR-002. Two more primitives are built on top of it, and both are available:
- Phase 1b:
wait_for_event— see Waiting for the user below. - Phase 1c:
TutorialMixin— a declarative state machine for guided tours. Describe the tour as a list ofTutorialStepentries (target, message, wait-for event, optional on-enter/on-exit chains) and callstart_tutorial(). The mixin handles step ordering, highlight cleanup, timeout handling, and skip/cancel. Zero boilerplate.
Phase 4 (multi-user broadcast, consent envelope) and Phase 5 (LLM-driven AssistantMixin) are planned to extend the primitive into multi-user and AI-driven scenarios. See ADR-002 for the full roadmap.
Waiting for the user
push_commands sends chains to the client, but by itself it doesn't know how to pause a background task until the user actually does something. That's what await self.wait_for_event(...) is for — it's the async primitive that makes "highlight this button, wait for the user to click it, then move on" work declaratively.
import asyncio
from djust import LiveView
from djust.decorators import event_handler, background
from djust.js import JS
class Onboarding(LiveView):
tour_running: bool = False
@event_handler
@background
async def start_tour(self, **kwargs):
self.tour_running = True
# Step 1: highlight the create button
self.push_commands(
JS.add_class("tour-highlight", to="#btn-new-project")
.focus("#btn-new-project")
)
# Deliver the highlight now; otherwise it waits until the task returns
await self.flush_push_events()
# Suspend until the user clicks it (which fires create_project)
try:
result = await self.wait_for_event("create_project", timeout=60)
except asyncio.TimeoutError:
self.tour_running = False
self.push_commands(JS.remove_class("tour-highlight", to="#btn-new-project"))
return
# User clicked — clean up the highlight and advance
self.push_commands(JS.remove_class("tour-highlight", to="#btn-new-project"))
self.project_name = result.get("name", "")
# Step 2: continue the tour...
@event_handler
def create_project(self, name: str, **kwargs):
"""Called when the user clicks the highlighted button."""
Project.objects.create(name=name, owner=self.request.user)
Signature
async def wait_for_event(
self,
name: str,
*,
timeout: Optional[float] = None,
predicate: Optional[Callable[[Dict[str, Any]], bool]] = None,
) -> Dict[str, Any]:
name— the name of the event handler to wait for. Must match a method decorated with@event_handler. Any call to that handler resolves the waiter (unless a predicate filters it out).timeout— optional seconds to wait. Raisesasyncio.TimeoutErrorwhen exceeded.None(the default) waits indefinitely. Catchasyncio.TimeoutError, not the builtinTimeoutError: on Python 3.10 they are different classes.predicate— optional callable that takes the handler's kwargs dict and returnsTrueto resolve orFalseto keep waiting. Useful for "wait for the user to click this specific button" when multiple events might fire the same handler with different arguments.- Returns — the kwargs dict that was passed to the matching handler.
Predicate examples
# Wait for the user to submit a form with a specific project id
result = await self.wait_for_event(
"submit_form",
predicate=lambda kw: kw.get("project_id") == 42,
timeout=30,
)
# Wait for any "save" event from a draft whose status is "ready"
result = await self.wait_for_event(
"save",
predicate=lambda kw: kw.get("status") == "ready",
)
# Wait for a click with no filter — first click wins
result = await self.wait_for_event("next_step")
A predicate that raises is treated as "no match" and logged — a buggy predicate can't crash the event pipeline or deadlock your background task.
Concurrency
Multiple background tasks can wait on the same event name simultaneously. When that event fires, every waiter whose predicate matches resolves with the same kwargs dict. This lets you build fan-out patterns like "three tutorial branches all waiting on the user's next action" without manual coordination.
Waiters for different event names are fully independent — notifying event_a never resolves a waiter for event_b.
Timeouts and cleanup
When a waiter times out, the framework removes it from the registry automatically — no stale waiters accumulate over the life of a view. When the view disconnects (WebSocket close, tab navigation, browser crash), the framework cancels all pending waiters on that view. Any @background task currently awaiting a waiter will unblock with asyncio.CancelledError, giving it a chance to clean up (remove highlights, persist partial state, emit analytics).
Integration with push_commands
The two primitives compose naturally. The pattern for any guided flow is:
- Push a chain that sets up the UI state (highlight, narrate, focus), then
await self.flush_push_events()so it reaches the client before you wait - Await a waiter for the event you want the user to trigger
- On resolution: push a chain that cleans up the UI state and sets up the next step
- Repeat
@event_handler
@background
async def run_multi_step_tour(self, **kwargs):
for step in self.tour_steps:
# Setup
self.push_commands(
JS.add_class("highlight", to=step["target"])
.dispatch("tour:narrate", detail={"text": step["message"]})
)
await self.flush_push_events()
# Wait for user action or timeout
try:
await self.wait_for_event(step["expect"], timeout=step.get("timeout", 60))
except asyncio.TimeoutError: # needs `import asyncio`
self.push_commands(JS.remove_class("highlight", to=step["target"]))
return # User abandoned the tour
# Cleanup + advance
self.push_commands(JS.remove_class("highlight", to=step["target"]))
This is exactly the state machine TutorialMixin formalizes — a list of steps, setup/wait/cleanup per step, skip/cancel handling — without the boilerplate.
Limitations
- Component events notify the parent view's waiters. When a
LiveComponentfires a handler, the parentLiveView's waiters for that event name resolve too. The kwargs dict includescomponent_id, so apredicatecan tell which component fired. - Actor-mode views bypass the dispatch hook. Views running under the experimental Rust actor system (
use_actors = True) don't notify waiters yet. The non-actor path is the default and is fully supported. wait_for_eventrequires the handler to actually run server-side. If the client fires an event that fails validation (missing params, auth error, etc.), the handler never executes and the waiter never resolves — only the timeout will unblock it.
See also
- JS Commands — the full command vocabulary (11 ops, scoped targets, immutable chains)
- ADR-002 — full design doc with motivation, alternatives, and the multi-user / AI follow-through
- Hooks — when to reach for client-side
dj-hooklifecycle callbacks instead - Debug Panel — inspecting push events and exec chains at runtime