Working with HTMX and Alpine¶
Shrd uses HTMX for server-driven updates and Alpine.js for client-side UI that does not need a round-trip. Both are bundled as static files — no npm, no build step.
This guide walks through how developers actually build interactive components: the workflow, when to reach for each tool, and full examples from the repository.
Mental model¶
┌─────────────────────────────────────────────────────────────┐
│ Page template │
│ {% shard_scripts alpine=True %} ← load once per page │
│ {% component Counter %}{% endcomponent %} │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Component template │
│ {% shard_root component %} ← HTMX swap target │
│ {% shard_htmx component "increment" %} ← POST to server │
│ {% shard_alpine component %} ← optional client state │
└─────────────────────────────────────────────────────────────┘
│
User click ─────┴─────► POST /shard/action/<id>/<action>/
│
▼
@action method updates state
│
▼
Component re-rendered → HTML fragment
│
▼
HTMX swaps fragment into DOM
HTMX owns persistence: counters, lists, form saves, validation. The server is the source of truth.
Alpine owns ephemeral UI: open/closed panels, focus rings, transitions, disabling a button while typing. It resets on full page reload unless you mirror values in server state.
See also: Actions, HTMX, Alpine, Events.
Page setup¶
Load scripts once in your page layout. Pass alpine=True when any component on the page uses {% shard_alpine %}:
The example app does this in example/templates/home.html. Without Alpine on the page, x-data directives are inert.
| Script | Purpose |
|---|---|
htmx.min.js |
POST actions, swap HTML fragments |
alpine.min.js |
x-data, x-show, @click, etc. |
shard.js |
Adds shard=1 to HTMX requests (framework marker) |
Workflow: building an HTMX-only component¶
Use HTMX alone when every interaction should update server state and re-render the component.
1. Define state and actions in Python¶
# example/components.py
from shard import Component, Prop, action, computed, emits
class Counter(Component):
initial = Prop(int, default=0)
step = Prop(int, default=1)
template_name = "components/counter.html"
def get_initial_state(self) -> dict:
return {"count": self.initial}
@computed
def label(self) -> str:
return f"Current count is {self.state['count']}"
@emits("counter:changed")
@action
def increment(self, state: dict) -> dict:
state["count"] += self.step
return state
@emits("counter:changed")
@action
def decrement(self, state: dict) -> dict:
state["count"] -= self.step
return state
2. Wire buttons in the template¶
{# example/templates/components/counter.html #}
{% load shard %}
<div {% shard_root component %} class="counter">
<p class="counter__value">
<strong>Count:</strong>
<span>{{ state.count }}</span>
</p>
<p class="counter__label"><small>{{ computed.label }}</small></p>
<div class="counter__actions">
<button type="button" class="counter__btn" {% shard_htmx component "increment" %}>
+{{ props.step }}
</button>
<button type="button" class="counter__btn" {% shard_htmx component "decrement" %}>
-{{ props.step }}
</button>
</div>
</div>
3. What happens on click¶
{% shard_htmx component "increment" %} expands to:
hx-post="/shard/action/<instance_id
>/increment/" hx-target="#shard-<instance_id
>" hx-swap="outerHTML"</instance_id
></instance_id
>
HTMX POSTs to Shrd, the increment action runs, state is saved, the component re-renders, and HTMX replaces the entire #shard-<id> element with the new HTML. The count in the response always matches server state.
4. Mount on a page¶
{% component Card title="Counter" %}
{% component Counter initial=3 step=2 %}{% endcomponent %}
{% endcomponent %}
Or from a view with mount(Counter, props={"initial": 3}).
5. Listen for events (optional)¶
@emits("counter:changed") adds that event to the HX-Trigger response header. Other components or page scripts can react:
Workflow: combining Alpine and HTMX¶
Use both when you need immediate client feedback and server persistence in the same component. The example TodoList is the canonical pattern.
1. Split server state and client state¶
class TodoList(Component):
placeholder = Prop(str, default="Add a task...")
template_name = "components/todo_list.html"
def get_initial_state(self) -> dict:
return {"items": [], "draft": ""} # persisted on server
def get_client_state(self) -> dict:
return {"focused": False} # Alpine only; resets on full reload
@action
def add_item(self, state: dict, text: str = "") -> ActionResult:
value = (text or state.get("draft", "")).strip()
if value:
state.setdefault("items", []).append(value)
state["draft"] = ""
return ActionResult.with_events(state, **{"todo:added": {"item": value}})
return ActionResult(state=state)
@action
def set_draft(self, state: dict, draft: str = "") -> dict:
state["draft"] = draft
return state
@action
def remove_item(self, state: dict, index: str = "0") -> dict:
...
| Data | Where | Updated by |
|---|---|---|
items, draft |
state (server) |
@action methods |
focused |
get_client_state() (client) |
Alpine @focus / @blur |
2. Template: Alpine on the root, HTMX on controls¶
{# example/templates/components/todo_list.html #}
{% load shard %}
<div {% shard_root component %} class="todo" {% shard_alpine component %}>
<form
class="todo__form"
{% shard_htmx component "add_item" %}
@submit.prevent="$el.requestSubmit()"
>
<input
class="todo__input"
type="text"
name="text"
placeholder="{{ props.placeholder }}"
value="{{ state.draft }}"
{% shard_htmx component "set_draft" trigger="keyup changed delay:300ms" %}
@focus="focused = true"
@blur="focused = false"
/>
<button class="todo__submit" type="submit">Add</button>
</form>
{% if state.items %}
<ul class="todo__items">
{% for item in state.items %}
<li class="todo__item">
<span>{{ item }}</span>
<button
type="button"
class="todo__remove"
{% shard_htmx component "remove_item" index=forloop.counter0 %}
>
Remove
</button>
</li>
{% endfor %}
</ul>
{% else %}
<p class="todo__empty"><em>No tasks yet.</em></p>
{% endif %}
</div>
Key techniques in this template:
| Technique | Why |
|---|---|
{% shard_alpine component %} on root |
Seeds x-data='{"focused": false}' from get_client_state() |
{% shard_htmx component "add_item" %} on <form> |
Submit POSTs text field to add_item |
@submit.prevent="$el.requestSubmit()" |
Alpine prevents full navigation; HTMX handles the POST |
{% shard_htmx ... trigger="keyup changed delay:300ms" %} on input |
Debounced draft sync to server without submitting |
name="text" on input |
Becomes text= keyword arg in add_item(self, state, text=...) |
index=forloop.counter0 on remove button |
Passed as index= to remove_item via hx-vals |
3. Debounced server sync¶
The draft input fires set_draft on keyup (300 ms debounce). Server state stays in sync if the user navigates away mid-edit, while Alpine handles focus styling without a round-trip:
<input
{% shard_htmx component "set_draft" trigger="keyup changed delay:300ms" %}
@focus="focused = true"
@blur="focused = false"
/>
You could also use :class="{ 'is-focused': focused }" for visual feedback driven by Alpine.
When to use HTMX vs Alpine¶
| Task | Tool | Example |
|---|---|---|
| Increment a counter | HTMX | Counter.increment |
| Persist a todo list | HTMX | TodoList.add_item |
| Sync draft text to server | HTMX (debounced) | TodoList.set_draft |
| Track input focus | Alpine | focused in get_client_state() |
| Toggle a dropdown open | Alpine | open = !open |
| Animate panel enter/leave | Alpine | x-show + x-transition |
| Save form to database | HTMX | @action + ActionResult |
| Navigate after save | HTMX | ActionResult(redirect="/done/") |
Rule of thumb: if it must survive a component re-render or page refresh, put it in server state. If it is purely visual and ephemeral, use Alpine.
Shared and global client state¶
Shrd seeds Alpine state per component via get_client_state() and {% shard_alpine %}. There is no built-in global Alpine store — that is intentional. The server remains the source of truth; Alpine handles local UI chrome.
What Shrd provides¶
| Need | Mechanism |
|---|---|
| Persisted data | Server state + @action |
| Component UI (focus, open, animate) | get_client_state() on that component |
| "A did something, B should react" | @emits / ActionResult + @event.window |
| Layout spanning multiple components | See patterns below |
Why not a framework global Alpine store?¶
A Shrd-managed global client store would introduce a second state graph that:
- Does not sync automatically with server
stateafter HTMX re-renders - Survives
outerHTMLswaps differently from per-componentx-data(sometimes useful, often a source of stale UI) - Encourages SPA-style patterns Shrd deliberately avoids
For shared client UI, use one of the patterns below in your app code. Shrd documents them; it does not need to own them.
Decision guide¶
| Need | Recommended approach |
|---|---|
| Counter, list items, form values | Server state + HTMX |
| Focus, hover, open/closed on one component | get_client_state() on that component |
| Component A notifies component B | HTMX events (@emits, @event.window) |
| Sidebar/theme across the whole page | Page-level x-data or Alpine.store() (app-owned) |
| User preference that survives refresh | Django session or database, not Alpine alone |
Pattern 1: HTMX events (preferred for coordination)¶
When one component should react to another's action — not share a mutable store — use events:
See Events.
Pattern 2: Lift client state to a parent¶
If two children need the same ephemeral UI, put Alpine on a parent that is not the HTMX swap target:
{# base.html — parent survives child HTMX swaps #}
<body x-data='{"sidebarOpen": false}'>
<aside x-show="sidebarOpen">...</aside>
<main>
{% component Counter %}{% endcomponent %}
</main>
</body>
Child components re-render via HTMX; layout chrome on <body> stays intact.
Pattern 3: Page-level x-data from Django¶
Seed layout UI once from a view or context processor:
# myapp/context_processors.py
import json
def alpine_layout(request):
return {
"alpine_page_state": json.dumps({
"sidebarOpen": False,
"theme": request.session.get("theme", "light"),
}),
}
{# templates/base.html #}
<body x-data='{{ alpine_page_state|safe }}'>
{% shard_scripts alpine=True %}
<button @click="sidebarOpen = !sidebarOpen">Menu</button>
...
</body>
This is standard Django → template → Alpine. Shrd does not wrap it because your app owns what belongs at page scope.
Pattern 4: Alpine.store() for app-wide reactivity¶
When distant components must read/write the same client value without server round-trips, register a store in your own script:
{% shard_scripts alpine=True %}
<script>
document.addEventListener("alpine:init", () => {
Alpine.store("ui", {
sidebarOpen: false,
toggleSidebar() {
this.sidebarOpen = !this.sidebarOpen;
},
});
});
</script>
<button @click="$store.ui.toggleSidebar()">Menu</button>
<aside x-show="$store.ui.sidebarOpen">...</aside>
Keep stores small and UI-only. Do not mirror server state in a global Alpine store — it will drift after HTMX swaps.
What to avoid¶
- Putting persisted business data (lists, counts, form values) in
Alpine.store() - Expecting global Alpine state to survive HTMX
outerHTMLswaps on the same element that owns it - Using a global store when HTMX events or server
statewould be simpler
Common patterns¶
POST form fields to an action¶
Any name attribute in the request becomes a keyword argument:
<input name="email" type="email" />
<button {% shard_htmx component "subscribe" %}>Subscribe</button>
Pass extra values without a form field¶
Renders hx-vals='{"index": 0}' alongside the POST.
Manual HTMX attributes¶
shard_htmx is sugar. Equivalent manual markup:
<button
hx-post="{{ shard_actions.increment }}"
hx-target="#shard-{{ shard_id }}"
hx-swap="outerHTML"
>
+
</button>
Use manual attributes when you need custom targets or swap strategies not covered by the tag.
Partial updates (innerHTML swap)¶
Default outerHTML replaces the whole component including scoped styles on first load. For large components, target an inner region:
<div {% shard_root component %}>
<header>...</header>
<div id="results-{{ shard_id }}">{{ state.results_html|safe }}</div>
</div>
<button {% shard_htmx component "search" target="#results-{{ shard_id }}" swap="innerHTML" %}>
Search
</button>
Alpine dropdown (client-only)¶
No HTMX needed when nothing is persisted:
class Dropdown(Component):
template_name = "components/dropdown.html"
def get_client_state(self) -> dict:
return {"open": False}
{% load shard %}
<div {% shard_root component %} {% shard_alpine component %} class="dropdown">
<button type="button" @click="open = !open" :aria-expanded="open">
Menu
</button>
<div x-show="open" x-transition @click.outside="open = false">
{{ slots.default|safe }}
</div>
</div>
React to another component's event in Alpine¶
@emits / ActionResult events are fired on document via HTMX's HX-Trigger header.
Debugging¶
- Network tab — action requests go to
/shard/action/<instance_id>/<action_name>/. Check POST body, response HTML, andHX-Triggerheader. python manage.py shard_list— lists registered components and their actions.- Missing swap — ensure
{% shard_root component %}is on the element HTMX targets (#shard-<id>by default). - Alpine not working — confirm
{% shard_scripts alpine=True %}is on the page. - CSRF errors — include
{% csrf_token %}somewhere on the page (Django convention). - State 404 — instance expired or cache cleared; remount the component.
Try the examples¶
- http://127.0.0.1:8000/ —
Counter(HTMX) andTodoList(Alpine + HTMX) - http://127.0.0.1:8000/dynamic/ — view-data layout with HTMX toolbar actions
Source files:
| Component | Python | Template |
|---|---|---|
| Counter | example/components.py |
example/templates/components/counter.html |
| TodoList | example/components.py |
example/templates/components/todo_list.html |