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
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}"),
]
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.