HTML Patches
Replace a full panel, patch one row, append to a feed, or delete a node outright with `HTML(...)` and `Delete(...)`.
pip install hyperdjango
HyperDjango 0.39 adds typed command-to-query handoffs: run a short mutation without retries, then switch into a long-lived, retryable read stream.
Full 0.39 release notes ↗Interrupt a watcher and see it reconnect while its command stays at one execution.
Run SwitchAction demo → 02 · Python APIUse watch.switch_to(...); declared metadata supplies its transport contract.
Transfer lane ownership without flicker, self-blocking, or inherited resume state.
Read runtime reference → 04 · InspectorLink source and destination actions while preserving their separate request IDs.
Open inspector guide →Full CRUD cycles with server-side state and instant partial updates.
{% include "partials/todo_stats.html" %}
{% include "partials/todo_empty.html" %}
<ul id="todo-list">
{% for todo in todos %}
{% include "partials/item.html" with todo=todo %}
{% endfor %}
</ul>
<li id="todo-{{ todo.id }}">
<input
type="checkbox"
@change="$action('toggle_todo', { id: '{{ todo.id }}' })"
/>
<span>{{ todo.title }}</span>
<button @click="$action('delete_todo', { id: '{{ todo.id }}' })">
Delete
</button>
</li>
from hyperdjango import HyperView
from hyperdjango.actions import Delete, HTML, Toast, action
class PageView(HyperView):
def todo_stats(self):
todos = Todo.objects.order_by("id")
completed = todos.filter(done=True).count()
return {
"todo_total": todos.count(),
"todo_completed": completed,
"todo_active": todos.count() - completed,
}
def get(self, request, **params):
todos = Todo.objects.order_by("id")
return {"todos": todos, **self.todo_stats()}
@action
def add_todo(self, request, title=""):
todo = Todo.objects.create(title=title, done=False)
return [
HTML(
content=self.render(
request=request,
relative_template_name="partials/item.html",
context_updates={"todo": todo},
),
target="#todo-list",
swap="append",
),
HTML(
content=self.render(
request=request,
relative_template_name="partials/todo_stats.html",
context_updates=self.todo_stats(),
),
),
HTML(
content=self.render(
request=request,
relative_template_name="partials/todo_empty.html",
context_updates={"has_todos": True},
),
),
]
@action
def toggle_todo(self, request, id=None):
todo = Todo.objects.get(id=id)
todo.done = not todo.done
todo.save()
return [
HTML(
content=self.render(
request=request,
relative_template_name="partials/item.html",
context_updates={"todo": todo},
),
),
HTML(
content=self.render(
request=request,
relative_template_name="partials/todo_stats.html",
context_updates=self.todo_stats(),
),
),
Toast(payload={
"type": "info",
"title": "Updated",
"message": "Todo state changed.",
}),
]
@action
def delete_todo(self, request, id=None):
todo = Todo.objects.get(id=id)
todo.delete()
return [
Delete(target=f"#todo-{id}"),
HTML(
content=self.render(
request=request,
relative_template_name="partials/todo_stats.html",
context_updates=self.todo_stats(),
),
),
HTML(
content=self.render(
request=request,
relative_template_name="partials/todo_empty.html",
context_updates={"has_todos": Todo.objects.exists()},
),
),
Toast(payload={
"type": "warning",
"title": "Deleted",
"message": "Todo removed.",
}),
]
Debounced input events triggering live server-side filtering.
<div x-data="{ q: '' }">
<p hyper-loading="live-search">
Searching...
</p>
<div class="relative">
<input
x-model="q"
@input.debounce.300ms="$action('search', { q }, { key: 'live-search' })"
/>
<div class="search-loading-frame" hyper-loading="live-search"></div>
</div>
{% include "partials/search_results.html" %}
</div>
<ul id="search-results-demo">
{% for result in search_results %}
<li>{{ result }}</li>
{% empty %}
{% if q %}
<li>No matches found for "{{ q }}"</li>
{% endif %}
{% endfor %}
</ul>
from hyperdjango import HyperView
from hyperdjango.actions import HTML, action
class PageView(HyperView):
def get(self, request, **params):
return {"search_results": []}
@action
def search(self, request, q=""):
results = SearchDocument.objects.filter(title__icontains=q)[:8]
html = self.render(
request=request,
relative_template_name="partials/search_results.html",
context_updates={
"search_results": results,
"q": q,
},
)
return [HTML(content=html)]
Searching...
A real chat flow: user prompt, streamed tool work, then an assistant answer that types into the thread.
<section x-data="{ prompt: '' }">
<div id="agent-thread-demo" class="agent-chat-thread">
{% include "partials/agent_log_empty.html" %}
</div>
<label class="block mb-2">Prompt</label>
<form
class="agent-chat-composer"
@submit.prevent="if (!prompt.trim()) return; $action('run_agent_stream', { prompt }, { method: 'POST', key: 'agent-stream' }); prompt = ''"
>
<div class="agent-chat-composer-field">
<input x-model="prompt" />
</div>
<button class="agent-chat-send">Send</button>
</form>
</section>
<div class="agent-chat-row agent-chat-row-user">
<div class="agent-chat-bubble agent-chat-bubble-user">
<div class="agent-chat-label">you</div>
<div class="agent-chat-text">{{ prompt }}</div>
</div>
</div>
<div id="agent-tool-message" class="agent-chat-bubble agent-chat-bubble-tool agent-chat-bubble-tool-live">
<div id="agent-log-text" class="agent-chat-tool-text"></div>
</div>
<span class="agent-log-chip">[{{ kind }}] {{ message }}</span>
<div class="agent-chat-row agent-chat-row-agent">
<div class="agent-chat-bubble agent-chat-bubble-agent">
<div class="agent-chat-label">assistant</div>
<div id="agent-response-demo" class="agent-chat-text"></div>
</div>
</div>
window.addEventListener("agent:scroll", () => {
queueMicrotask(() => {
const thread = document.getElementById("agent-thread-demo");
if (!(thread instanceof HTMLElement)) return;
thread.scrollTop = thread.scrollHeight;
});
});
from hyperdjango import HyperView
from hyperdjango.actions import Event, HTML, Toast, action
class PageView(HyperView):
@action
def run_agent_stream(self, request, prompt=""):
prompt = (
prompt.strip()
or "Explain how HyperDjango streams server events to the UI."
)
yield HTML(content="", target="#agent-thread-demo", swap="inner")
yield HTML(
content=self.render(
request,
"partials/agent_user_message.html",
{"prompt": prompt},
),
target="#agent-thread-demo",
swap="append",
)
yield Event(name="agent:scroll", payload={})
yield HTML(
content=self.render(
request,
"partials/agent_log_shell.html",
),
target="#agent-thread-demo",
swap="append",
)
yield Event(name="agent:scroll", payload={})
for kind, message in [
("plan", f"Mapping request: {prompt}"),
("tool", "Scanning partials and action handlers"),
("write", "Preparing streamed UI patches"),
("test", "Running checks before answering"),
]:
yield HTML(
content=self.render(
request,
"partials/agent_log_chunk.html",
{"kind": kind, "message": message},
),
target="#agent-tool-text",
swap="inner",
)
yield Event(name="agent:scroll", payload={})
yield HTML(
content=self.render(
request,
"partials/agent_log_shell_done.html",
),
target="#agent-tool-message",
)
yield HTML(
content=self.render(
request,
"partials/agent_log_chunk.html",
{
"kind": "test",
"message": "Checks passed. Starting final response stream",
},
),
target="#agent-log-text",
swap="inner",
)
yield Event(name="agent:scroll", payload={})
yield HTML(
content=self.render(
request,
"partials/agent_assistant_shell.html",
),
target="#agent-thread-demo",
swap="append",
)
yield Event(name="agent:scroll", payload={})
response = (
"I parsed your prompt and built a plan. "
"Then the tool phase streamed into a single evolving status bubble. "
"After the checks passed, the final answer streamed into the assistant bubble."
)
chunks = [segment + " " for segment in response.split()]
if chunks:
chunks[-1] = chunks[-1].rstrip()
for chunk in chunks:
yield HTML(
content=self.render(
request,
"partials/agent_response_chunk.html",
{"chunk": chunk},
),
target="#agent-response-demo",
swap="append",
)
yield Event(name="agent:scroll", payload={})
yield Toast(
payload={
"type": "success",
"title": "Agent finished",
"message": "Stream complete.",
}
)
Transform static content into interactive forms with a single action.
<div id="inline-editor-demo" x-data="{ text: '{{ inline_text }}' }">
{% if editing %}
<form @submit.prevent="$action('save_inline', { text })">
<input x-model="text" @keydown.escape="$action('save_inline', {})" />
</form>
{% else %}
<button @click="$action('edit_inline')">{{ inline_text }}</button>
{% endif %}
</div>
from hyperdjango import HyperView
from hyperdjango.actions import HTML, action
class PageView(HyperView):
def get(self, request, **params):
profile = Profile.objects.get(pk=1)
return {"inline_text": profile.bio, "editing": False}
@action
def edit_inline(self, request):
profile = Profile.objects.get(pk=1)
html = self.render(
request=request,
relative_template_name="partials/inline_editor.html",
context_updates={
"inline_text": profile.bio,
"editing": True,
},
)
return [HTML(content=html)]
@action
def save_inline(self, request, text=""):
profile = Profile.objects.get(pk=1)
profile.bio = text or profile.bio
profile.save()
html = self.render(
request=request,
relative_template_name="partials/inline_editor.html",
context_updates={
"inline_text": profile.bio,
"editing": False,
},
)
return [HTML(content=html)]
Upload progress can stream from the browser while the server returns a normal HyperDjango action response.
<form id="upload-form-demo" x-data="uploadDemo()" method="POST" enctype="multipart/form-data">
<label class="upload-picker">
<span>Choose file</span>
<span x-text="filename"></span>
<input type="file" name="upload" class="sr-only" @change="reset('#upload-form-demo')" />
</label>
<button
type="button"
@click="reset('#upload-form-demo'); $action('upload_demo', {}, { form: '#upload-form-demo', key: 'upload-demo', onUploadProgress() {} })"
>
Upload
</button>
<div class="relative border border-black/10 h-3">
<div :style="`width:${progress}%`"></div>
</div>
{% include "partials/upload_result.html" %}
</form>
Alpine.data("uploadDemo", () => ({
progress: 0,
status: "idle | 0%",
filename: "No file chosen",
init() {
window.addEventListener("hyper:uploadProgress", (event) => {
if (event.detail.key !== "upload-demo") return;
const percent = Math.round((event.detail.progress ?? 0) * 100);
this.progress = percent;
this.status = `uploading | ${percent}%`;
});
window.addEventListener("hyper:afterRequest", (event) => {
if (event.detail.key !== "upload-demo") return;
this.progress = event.detail.ok ? 100 : 0;
this.status = event.detail.ok ? "done | 100%" : "error | 0%";
});
},
reset(formSelector) {
const form = document.querySelector(formSelector);
const input = form?.querySelector('input[type="file"][name="upload"]');
const hasFile = !!input?.files?.length;
this.filename = input?.files?.[0]?.name || "No file chosen";
this._expectUpload = hasFile;
this.progress = 0;
this.status = hasFile ? "starting | 0%" : "error | 0%";
},
}));
from hyperdjango import HyperView
from hyperdjango.actions import HTML, Toast, action
class PageView(HyperView):
@action
def upload_demo(self, request, **kwargs):
upload = request.FILES.get("upload")
if upload is None:
html = self.render(
request=request,
relative_template_name="partials/upload_result.html",
context_updates={
"error": "Choose a file before uploading.",
"uploaded_name": "",
"uploaded_size_kb": "",
"content_type": "",
},
)
return [HTML(content=html, target="#upload-result-demo")]
html = self.render(
request=request,
relative_template_name="partials/upload_result.html",
context_updates={
"uploaded_name": upload.name,
"uploaded_size_kb": round(upload.size / 1024),
"content_type": upload.content_type,
"error": "",
},
)
return [
HTML(content=html, target="#upload-result-demo"),
Toast(payload={"type": "success", "title": "Upload complete", "message": f"Received {upload.name}."}),
]
Update Alpine state directly from the server without swapping any HTML.
<section x-data="{ count: 0, status: 'Idle' }">
<p>Count: <span x-text="count"></span></p>
<p x-text="status"></p>
<button @click="$action('increment_signal_demo', { count })">
Increment Signal
</button>
</section>
from hyperdjango import HyperView
from hyperdjango.actions import Signal, action
class PageView(HyperView):
@action
def increment_signal_demo(self, request, count=0):
next_count = int(count) + 1
return [
Signal(name="count", value=next_count),
Signal(name="status", value=f"Synced from server at count {next_count}"),
]
Run a short, non-retried POST once, then hand the same loading workflow to a retryable, read-only GET stream.
from hyperdjango.actions import Signal, action
class PageView(HyperView):
@action(method="POST", retry=False)
def start_build(self, request, package_id: str):
# Commit a bounded transaction or durably enqueue work.
job = start_package_build(package_id=package_id)
# The Python reference supplies the action name, GET method,
# retry policy, and current endpoint automatically.
return self.watch_build.switch_to(job_id=str(job.pk))
@action(method="GET", retry=True)
def watch_build(self, request, job_id: str):
# Read-only and safe to execute again after reconnecting.
for snapshot in watch_job(job_id):
yield Signal(name="build", value=snapshot)
<button
hyper-loading-disable="package-build"
@click="$action(
'start_build',
{ package_id },
{
method: 'POST',
retry: false,
key: 'package-build'
}
)"
>
Build package
</button>
<div hyper-loading-key="package-build">
<progress :value="build.progress" max="100"></progress>
</div>
switch_to() does not make the POST idempotent. Disable retry on the command, persist a discoverable outcome before switching, and keep the destination strictly read-only and replay-safe.
Ready to build
This demo deliberately drops the watcher after its first event. HyperDjango reconnects the GET with the same watcher request ID and Last-Event-ID; the POST remains at exactly one execution.
HyperDjango keeps the server in control, but still gives you the UI primitives needed for modern interactive apps.
Replace a full panel, patch one row, append to a feed, or delete a node outright with `HTML(...)` and `Delete(...)`.
Define a route with `+page.py`, compose it with partials, and keep your URL structure obvious from the filesystem.
Long-lived actions can stream many updates in sequence: tool output, live feeds, test phases, and token-like response chunks.
Send `Signal(...)` updates straight into Alpine state when the UI only needs fresh values, not a new HTML fragment.
Track upload progress in the browser, dispatch custom events, and surface success or error states with server-driven toasts.
Push URLs, replace URLs, or redirect outright as part of the same action response that updates the UI.
HyperDjango speaks Alpine fluently: actions, signals, loading states, and direct browser-side feedback all fit cleanly.
Ship JavaScript only when a route truly needs it, while keeping the default interaction model server-first and partial-driven.
The common path stays simple: actions return HTML, signals, events, redirects, or toasts, and the browser applies them without a client-side SPA runtime.
HyperDjango gives you the interaction model developers want from modern frontend tools without making Django give up control of rendering, state, or flow.
You can ship rich interactivity without moving routing, rendering, and business logic into a separate client runtime.
Actions return `HTML`, `Delete`, `Signal`, `Event`, or `Toast` responses directly, so the server describes outcomes instead of micromanaging the browser.
Load JavaScript only where it helps, stream responses when it matters, and keep the common path simple and fast.