Skip to content

Update page fragments with htmx

htmx lets HTML elements send HTTP requests and replace parts of the current page with HTML from the response, so an application can update the page without a full reload.

In an ecommerce site, adding an item to a cart can change the cart badge, total, mini cart, and shipping message. In this library, a fragment is a Jinja template that renders one such part of the page. HTMXResponse can return all affected fragments with the response to the cart request.

The add_to_cart function below runs when Starlette receives an add-to-cart request. After changing the cart, it returns Added. as the response content and names the change cart.changed:

async def add_to_cart(request):
    await request.app.state.cart.add(request.path_params["sku"])
    return HTMXResponse("Added.", trigger="cart.changed")

A trigger names an application change, such as cart.changed, and each fragment lists the triggers that require it to update. Because the route reports the change and fragments declare their own dependencies, another fragment can listen for cart.changed without changing the route function.

Use the htmx documentation to load htmx in the browser and create requests with attributes such as hx-post and hx-target.

Create a fragment

Add a template to a fragments/ folder under a served directory. Declare its triggers, and give its root element the fragment name as its id:

{# templates/fragments/cart_badge.html.jinja #}
{% set triggers = ["cart.changed"] %}
{% sql count %}SELECT count(*) AS n FROM cart{% endsql %}
<span id="cart_badge" class="badge">{{ fetch_value(queries.count) }}</span>

The template receives request and its own QuerySet as queries, so this fragment needs no Python context function.

StaticFiles derives the name cart_badge from the filename. Fragment templates can end in .html.jinja, .html.j2, .html, .jinja, or .j2. The name also becomes the Jinja tag {% cart_badge %}.

Discovery raises an error when a fragment declares no triggers or when two templates have the same name. HTMXResponse raises an error when an updated fragment's root element has no matching id.

Add a context function

Use an async context function when Python must provide values to the template. Give the function the fragment name, accept the request, and return a mapping:

# store.py
async def cart_badge(request):
    return {"count": await request.app.state.cart.count()}
{# templates/fragments/cart_badge.html.jinja #}
{% set triggers = ["cart.changed"] %}
<span id="cart_badge" class="badge">{{ count }}</span>

The Jinja tag and later updates call the same context function.

Configure StaticFiles

Mount StaticFiles with the optional context functions:

statics = StaticFiles(
    loader=FileSystemLoader("templates"),
    html=True,
    fragments=store,
)

app = Starlette(
    routes=[
        Route("/cart/add/{sku}", add_to_cart, methods=["POST"]),
        Mount("/", app=statics),
    ]
)

Omit fragments when every fragment gets its data from the template. StaticFiles discovers the fragments/ folder during construction.

HTMXResponse finds the fragments owned by the mounted StaticFiles through scope["app"]. StaticFiles also serves each discovered fragment at GET /fragment/<name>.

Choose context sources

The fragments argument accepts a module, a mapping, a function, or a sequence containing any of them:

fragments=store                                    # resolve module attributes
fragments=[store, admin_store]                     # search several modules
fragments=[cart_badge, cart_total]                 # use function __name__ values
fragments=[store, {"promo_banner": render_promo}]  # resolve an explicit mapping key

Discovery resolves only names used by fragment templates. If two sources provide a context function for the same fragment, construction raises an error.

Place a fragment on a page

Render a discovered fragment with its Jinja tag:

<header>
  {% cart_badge %}
  {% cart_total %}
</header>

StaticFiles fixes the available tag names during construction. A misspelled tag such as {% cart_bagde %} raises a Jinja template syntax error.

Markdown rendered with include_markdown() can use the same tags. See Shortcodes and fragments in Markdown for the required spacing.

Trigger fragment updates

Return an HTMXResponse with the trigger name:

return HTMXResponse("Added.", trigger="cart.changed")

The content value goes to the request's htmx target. The trigger value starts as an HX-Trigger response header. Pass a sequence when one request makes several changes.

Before sending the response, HTMXResponse renders every fragment that lists one of those triggers. It renders the fragments concurrently and appends them to the body. The hx-swap-oob="true" attribute tells htmx to replace the page element with the same id:

Added Leather boots.
<span id="cart_badge" class="badge" hx-swap-oob="true">1</span>
<span id="cart_total" class="total" hx-swap-oob="true">$129.00</span>
<ul id="mini_cart" class="mini-cart" hx-swap-oob="true">...</ul>

When every matching fragment travels in the response body, HTMXResponse removes the HX-Trigger header because the updates are complete. If no fragment lists the trigger, the body and header remain unchanged, so htmx can handle the trigger in the browser.

Pull a slow fragment

If a fragment would delay the triggering response, set pull in its template:

{% set triggers = ["cart.changed"] %}
{% set pull = true %}
<div id="recommendations">...</div>

The fragment's Jinja tag emits a wrapper that fetches the fragment after htmx receives the trigger:

<div
  hx-get="/fragment/recommendations"
  hx-trigger="cart.changed from:body"
  hx-swap="outerHTML"
>
  <div id="recommendations">...</div>
</div>

The triggering response omits the pull fragment from its body and keeps HX-Trigger in htmx's JSON form. StaticFiles answers the follow-up request. A site mounted at /shop uses /shop/fragment/recommendations; an unknown fragment URL receives the site's normal 404 response.

Update fragments from other response classes

HTMXResponse updates matching fragments itself. Install HTMXMiddleware when another response class supplies the HX-Trigger header.

StaticFiles stores the fragments it discovers in statics.fragments. This FragmentRegistry maps trigger names to fragment templates and knows how to render them. Pass it to the middleware so both use the same fragments:

app.add_middleware(HTMXMiddleware, fragments=statics.fragments)

The middleware buffers a matching response body so it can append fragments and update Content-Length. Install it innermost, before middleware that rewrites the body, such as GZip. It leaves an HTMXResponse that already cascaded unchanged.

The middleware also serves GET /fragment/<name> when the app has no mounted StaticFiles site.

Constraints

  • StaticFiles builds its read-only list of fragments during construction. Registering fragments after the app starts serving is unsupported.
  • A context function can read the app, path, and headers from its request. It must not read the body because the route function has already consumed it.
  • Pass HTMXResponse(..., fragments=statics.fragments) when the mounted StaticFiles is unreachable through scope["app"], such as in a unit test.
  • Trigger matching is exact. A trigger such as cart.* has no glob behavior.
  • Each response update uses the triggers named by the route function. Rendering one fragment does not trigger another.
  • Each fragment must render independently of page-local values such as a loop variable.