Skip to content

HTMX Fragments

One click often makes several parts of a page wrong at once. Add an item to a cart and the header badge, the running total, the mini cart, and the free-shipping banner all need to change.

A fragment is a region of the page that re-renders itself when something happens. StaticFiles finds your fragments, and HTMXResponse carries every stale one back in the same response. The handler names none of them.

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

That handler is the whole payoff. It changes one thing and says what happened. Every fragment registered for cart.changed updates, and adding a sixth fragment does not touch it.

Writing a fragment

A fragment is two files, and one name shared between them.

Write an async function that returns the template context. Its name is the fragment's name:

# store.py
async def cart_badge(request):
    return {"count": await request.app.state.cart.count()}

Write the template it renders, in a fragments/ folder under a served directory. The template declares what makes it stale, and its root element carries the fragment's name as an id:

{# templates/fragments/cart_badge.html.jinja #}
{% set triggers = ["cart.changed"] -%}
<span id="cart_badge" class="badge">{{ count }}</span>

One name does four jobs: the function, the template file, the id on the root element, and the Jinja tag a page writes. None of the four can drift, because three of them come from the first. A mismatched id raises at render time instead of swapping nothing.

Wiring it up

There is no wiring. One argument on StaticFiles is the entire setup:

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

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

fragments=store names the module that holds the context functions. StaticFiles then scans a fragments/ folder under each served directory, the same way it scans shortcodes/, and turns every template in it into a fragment. A fragment template may be spelled .html.jinja, .html.j2, .html, .jinja, or .j2; the whole suffix comes off to leave the fragment's name.

No middleware, and nothing passed from the site to the handler. An HTMXResponse finds the fragments by looking for the StaticFiles mounted in the app the request reached, so a handler anywhere in the app cascades them. The site serves GET /fragment/<name> itself, so a fragment that fetches itself needs no route either.

Where the context functions come from

A module is the common case, not the only one. fragments takes any of these, or a sequence mixing them:

Source Looked up by Use it for
A module attribute name The usual case: one module per area.
A mapping key Naming a fragment something other than its function's name.
A function its __name__ A handful of fragments, with no module to hold them.
fragments=store                                    # one module
fragments=[store, admin_store]                     # several
fragments=[cart_badge, cart_total]                 # the functions themselves
fragments=[store, {"promo_banner": render_promo}]  # a module, plus one named by hand

Each fragment is resolved by the name its template carries, so two modules that happen to share an unrelated symbol never collide. Two sources that both offer the fragment a template asks for do collide, and raise — the name is a DOM id, so there is no way to render both.

Placing a fragment on the page

Each discovered fragment is a Jinja tag of its own name:

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

The tag set is closed and fixed when StaticFiles is built, so {% cart_bagde %} is a template syntax error naming the unknown tag, not a silent blank.

The tag renders the fragment through the same context function that produces its updates, so a fragment cannot drift between how it first paints and how it re-renders.

A Markdown file included with include_markdown() places a fragment the same way, and the fragment keeps its id through the Markdown pass. See Shortcodes and fragments in Markdown for the spacing rules Markdown imposes.

Firing a trigger

A handler returns an HTMXResponse naming what happened:

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

content is the handler's own answer, which goes into the element that made the request. trigger becomes an HX-Trigger header — htmx's own way of saying an event happened. Pass a list to fire several.

Before that response goes out, it renders every fragment those triggers make stale and appends each to its own body as an out-of-band swap. htmx swaps any top-level element marked hx-swap-oob="true" into the element with the same id, wherever it sits on the page.

One click produces this:

$ curl -i -X POST localhost:8000/cart/add/boot

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>

Fragments render concurrently, so a cascade costs about the slowest fragment rather than the sum of them.

The work happens in the response's __call__, which is async, rather than in __init__, which cannot be. So nothing is buffered and no head is rewritten — the body is still the response's own attribute when the fragments are appended to it.

A trigger no fragment is registered for leaves the response untouched, so a handler can still fire a purely client-side htmx event. So does a response in an app that serves no fragments at all.

Slow fragments: pull

A fragment backed by a slow service puts its latency on the critical path of the click. Declare pull in its template and it fetches itself instead:

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

That is the entire change — no new route, no page edit, no wiring. The fragment stays out of the response body, the HX-Trigger header survives so the browser hears about the event, and the fragment's tag emits the element that re-fetches it:

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

StaticFiles answers that URL for any fragment it discovered, so your app declares no route for it. The URL sits under the mount, so a site mounted at /shop serves /shop/fragment/recommendations and the first paint points there. A path under the prefix naming nothing registered falls through to the ordinary 404.

Measured against the example app:

Request Time What it carries
POST /cart/add/hat 0.6 ms Four fragments, rendered concurrently
GET /fragment/recommendations 401 ms The pull fragment, fetched afterwards

What you do not write

You skip Because
Any wiring at all fragments=store on StaticFiles is the whole setup.
A route for serving a fragment The site answers /fragment/<name>.
hx-get and hx-trigger markup The tag emits it, including htmx's required from:body.
hx-swap-oob="true" Added to each fragment on its way out.
Header handling HX-Trigger is written, read, and rewritten for you.
A list of fragments Discovery finds them. Adding one is a template and a function.

The response drops the HX-Trigger header once every stale fragment has ridden along inline: the work is done, so waking the browser would buy nothing. It keeps the header, normalized to htmx's JSON form, when something still has to be pulled.

When you need HTMXMiddleware

Only an HTMXResponse carries a cascade. To cascade a response of some other class — one another library built, or a header set above the handler — install HTMXMiddleware, which does the same work by intercepting any response that carries an HX-Trigger header:

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

It costs what the response avoids: the head is held back and the body collected so Content-Length can be corrected. Install it innermost, before anything that rewrites the body such as GZip. A response that already cascaded itself is left alone, so the two layers never both append the same fragments.

Things to know

  • The registry is read-only once the app serves. It is built when StaticFiles is constructed and shared by every request without a lock. Registering a fragment on a running app is not supported.
  • A context function must not read the request body. It is handed a request built from the scope, and the handler has already consumed the body. It can read the app, the path, and the headers.
  • The response finds its fragments through the app. It reads scope["app"] and searches the routes for the mounted site, caching what it finds. Pass HTMXResponse(..., fragments=...) when the site is not reachable that way — a unit test, or fragments served by something other than a mount.
  • Trigger matching is flat and exact. There is no cart.* globbing.
  • Fragments do not chain. A template declares what makes it stale; it cannot fire a trigger, so one click's blast radius is readable from the handler.
  • A fragment has to render standalone. One that closes over a page's loop variable fails at render time; nothing detects it at discovery.

Example

The htmx cascade example is a runnable cart: five fragments, two routes, and no handler that names one.

uv run uvicorn app:app --reload --app-dir examples/htmx_cascade