Fragments

Render named template blocks independently for htmx

3 min read 516 words

The Key Innovation

Most Python frameworks treat templates as "render a full page, return a string." Chirp can render a named block from a template independently, without rendering the rest of the page.

This is what makes htmx integration seamless. The browser requests a fragment, the server returns just the block it needs.

Fragment

Fragmentrenders a specific block from a template:

from chirp import Fragment

@app.route("/search")
def search(request: Request):
    results = do_search(request.query.get("q", ""))
    if request.is_fragment:
        return Fragment("search.html", "results_list", results=results)
    return Template("search.html", results=results)

Arguments:

  1. Template path
  2. Block name (must exist in the template)
  3. Keyword arguments become the rendering context

The template:

{% extends "base.html" %}

{% block content %}
  <input type="search" hx-get="/search" hx-target="#results" name="q">

  {% block results_list %}
    <div id="results">
      {% for item in results %}
        <div class="result">{{ item.title }}</div>
      {% endfor %}
    </div>
  {% endblock %}
{% endblock %}

Full page request renders everything (base layout + content + results). Fragment request renders only results_list -- the <div id="results">and its contents.

request.is_fragment

TheRequestobject detects htmx requests automatically:

request.is_fragment      # True if HX-Request header present
request.htmx_target      # Value of HX-Target header (e.g., "#results")
request.htmx_trigger     # Value of HX-Trigger header
request.is_history_restore  # True if htmx history restore

Page

Pageis syntactic sugar that auto-detects whether to return a full page or a fragment:

from chirp import Page

@app.route("/search")
def search(request: Request):
    results = do_search(request.query.get("q", ""))
    return Page("search.html", "results_list", results=results)

If request.is_fragment is True, it renders the block. Otherwise, it renders the full template. This eliminates the if/elsepattern.

OOB (Out-of-Band Swaps)

Sometimes a single action needs to update multiple parts of the page.OOBsends a primary fragment plus additional out-of-band fragments in one response:

from chirp import OOB, Fragment

@app.route("/cart/add", methods=["POST"])
async def add_to_cart(request: Request):
    item = await add_item(request)
    return OOB(
        Fragment("cart.html", "cart_items", items=get_cart()),
        Fragment("layout.html", "cart_count", count=cart_count()),
        Fragment("layout.html", "total_price", total=cart_total()),
    )

The first fragment is the main response. Additional fragments are appended with hx-swap-oob="true", so htmx swaps them into the correct locations on the page.

ValidationError

A specialized fragment for form validation errors. Returns a 422 status:

from chirp import ValidationError

@app.route("/register", methods=["POST"])
async def register(request: Request):
    form = await request.form()
    errors = validate_registration(form)
    if errors:
        return ValidationError("register.html", "form_errors", errors=errors)
    # ... create user

This renders the form_errors block with a 422 status code, which htmx can handle with hx-target-422or a custom error handler.

Block Availability

Only blocks that the template explicitly defines or overrides are available for fragment rendering. Inherited parent blocks that are not overridden in the child template are not available.

{# child.html #}
{% extends "base.html" %}

{% block content %}
  {# This block IS available as a fragment #}
  {% block search_results %}
    <div id="results">...</div>
  {% endblock %}
{% endblock %}

{# The "nav" block from base.html is NOT available #}
{# unless child.html explicitly overrides it #}

Next Steps