Working with htmx

htmx gives you AJAX, CSS transitions, WebSockets, and Server-Sent Events directly in HTML. It pairs beautifully with Jx components.

Setup

Include htmx in your layout:

components/layout.jinja
{#def title #}

<!DOCTYPE html>
<html>
<head>
  <title>{{ title }}</title>
  <script src="https://unpkg.com/htmx.org@2.0.4"></script>
  {{ assets.render_css() }}
</head>
<body>
  {{ content }}
  {{ assets.render_js() }}
</body>
</html>

Passing htmx Attributes

Jx automatically converts underscores to dashes, so htmx attributes work naturally:

{#import "button.jinja" as Button #}

<Button
  hx_get="/api/data"
  hx_target="#results"
  hx_swap="innerHTML"
>
  Load Data
</Button>

Renders as:

<button hx-get="/api/data" hx-target="#results" hx-swap="innerHTML">
  Load Data
</button>

htmx Button Component

components/htmx-button.jinja
{#def
  text,
  url,
  method="get",
  target="",
  swap="innerHTML",
  confirm=""
#}

<button
  hx-{{ method }}="{{ url }}"
  {% if target %}hx-target="{{ target }}"{% endif %}
  hx-swap="{{ swap }}"
  {% if confirm %}hx-confirm="{{ confirm }}"{% endif %}
  {{ attrs.render(class="btn") }}
>
  {{ text }}
</button>
usage
{#import "htmx-button.jinja" as HtmxButton #}

<HtmxButton
  text="Load More"
  url="/api/items?page=2"
  target="#item-list"
  swap="beforeend"
/>

<HtmxButton
  text="Delete"
  url="/api/items/123"
  method="delete"
  confirm="Are you sure?"
  class="btn-danger"
/>

Loading States

components/loading-button.jinja
{#def text, loading_text="Loading..." #}
{#css loading-button.css #}

<button {{ attrs.render(class="btn") }}>
  <span class="btn-text">{{ text }}</span>
  <span class="btn-loading">{{ loading_text }}</span>
</button>
loading-button.css
.btn .btn-loading {
  display: none;
}

.btn.htmx-request .btn-text {
  display: none;
}

.btn.htmx-request .btn-loading {
  display: inline;
}
usage
<LoadingButton
  text="Submit"
  loading_text="Submitting..."
  hx_post="/api/form"
/>

Partial Components

Create components that render just the part that changes:

components/todo-item.jinja
{#def todo #}

<li id="todo-{{ todo.id }}" class="todo-item">
  <span class="{{ 'completed' if todo.done else '' }}">
    {{ todo.text }}
  </span>
  <button
    hx-patch="/todos/{{ todo.id }}/toggle"
    hx-target="#todo-{{ todo.id }}"
    hx-swap="outerHTML"
  >
    {{ "Undo" if todo.done else "Done" }}
  </button>
  <button
    hx-delete="/todos/{{ todo.id }}"
    hx-target="#todo-{{ todo.id }}"
    hx-swap="outerHTML"
  >
    Delete
  </button>
</li>
views.py
@app.patch("/todos/<id>/toggle")
def toggle_todo(id):
    todo = toggle_todo_status(id)
    return catalog.render("todo-item.jinja", todo=todo)

@app.delete("/todos/<id>")
def delete_todo(id):
    delete_todo_by_id(id)
    return ""  # Empty response removes the element

Search with Debounce

components/search-input.jinja
{#def url, target, placeholder="Search..." #}

<input
  type="search"
  name="q"
  placeholder="{{ placeholder }}"
  hx-get="{{ url }}"
  hx-target="{{ target }}"
  hx-trigger="input changed delay:300ms, search"
  hx-indicator="#search-indicator"
  {{ attrs.render(class="search-input") }}
/>
<span id="search-indicator" class="htmx-indicator">Searching...</span>
usage
{#import "search-input.jinja" as SearchInput #}

<SearchInput url="/api/search" target="#results" />
<div id="results"></div>

Infinite Scroll

components/infinite-scroll.jinja
{#def items, next_url="" #}

{% for item in items %}
  <div class="item">{{ item.name }}</div>
{% endfor %}

{% if next_url %}
  <div
    hx-get="{{ next_url }}"
    hx-trigger="revealed"
    hx-swap="outerHTML"
    class="loading-more"
  >
    Loading more...
  </div>
{% endif %}
views.py
@app.get("/items")
def list_items():
    page = request.args.get("page", 1, type=int)
    items = get_items(page=page, per_page=20)
    next_url = f"/items?page={page + 1}" if items.has_next else ""
    return catalog.render("infinite-scroll.jinja", items=items, next_url=next_url)
components/htmx-modal.jinja
{#def id, title="" #}
{#css modal.css #}

<div id="{{ id }}" class="modal">
  <div class="modal-backdrop" hx-on:click="this.closest('.modal').remove()"></div>
  <div class="modal-content">
    {% if title %}
      <div class="modal-header">
        <h2>{{ title }}</h2>
        <button hx-on:click="this.closest('.modal').remove()">&times;</button>
      </div>
    {% endif %}
    <div class="modal-body">
      {{ content }}
    </div>
  </div>
</div>
usage
{# Button that loads modal content #}
<button
  hx-get="/modals/edit-user/123"
  hx-target="body"
  hx-swap="beforeend"
>
  Edit User
</button>
views.py
@app.get("/modals/edit-user/<id>")
def edit_user_modal(id):
    user = get_user(id)
    return catalog.render("modals/edit-user.jinja", user=user)
components/modals/edit-user.jinja
{#import "../htmx-modal.jinja" as Modal #}
{#import "../forms/input.jinja" as Input #}

<Modal id="edit-user-modal" title="Edit User">
  <form hx-put="/users/{{ user.id }}" hx-swap="none">
    <Input name="name" label="Name" value={{ user.name }} />
    <Input name="email" label="Email" value={{ user.email }} />
    <button type="submit">Save</button>
  </form>
</Modal>

Form Validation

components/validated-input.jinja
{#def name, label, validation_url #}

<div class="form-group">
  <label for="{{ name }}">{{ label }}</label>
  <input
    name="{{ name }}"
    id="{{ name }}"
    hx-post="{{ validation_url }}"
    hx-trigger="blur"
    hx-target="next .error"
    hx-swap="innerHTML"
    {{ attrs.render(class="form-control") }}
  />
  <span class="error"></span>
</div>
views.py
@app.post("/validate/email")
def validate_email():
    email = request.form.get("email")
    if not is_valid_email(email):
        return "Please enter a valid email"
    if email_exists(email):
        return "This email is already registered"
    return ""  # No error

Out-of-Band Updates

Update multiple parts of the page:

components/notification-badge.jinja
{#def count #}

<span id="notification-count" hx-swap-oob="true" class="badge">
  {{ count }}
</span>
views.py
@app.post("/notifications/mark-read/<id>")
def mark_read(id):
    mark_notification_read(id)
    count = get_unread_count()
    # Return both the main response and OOB update
    return catalog.render_string("""
        <div>Notification marked as read</div>
        {#import "notification-badge.jinja" as Badge #}
        <Badge count={{ count }} />
    """, count=count)

Tips

  1. Use hx-boost on links and forms for easy progressive enhancement
  2. Render partials - Create small components that return just what changed
  3. Use loading indicators with hx-indicator for better UX
  4. Leverage OOB swaps to update multiple page sections
  5. Keep components small - htmx works best with focused partials