I’ve enjoyed using fasthtml to deploy small, easily hosted webpages for little apps I’ve been building. I’m still getting used to it but it almost no effort at all to deploy. Recently, I built an app that would benefit from having a loading spinner upon submitting a form, but I couldn’t quite figure out how I would do that with htmx in FastHTML, so I built a small project to experiment with various approaches. This is what I came up with:

import time
from fasthtml.common import *

app, rt = fast_app(
    hdrs=(
        Style("""
            body {
                padding-top: 2rem;
                width: 70%;
                margin: 0 auto;
            }
            input {
                width: 100%;
                padding: 10px;
                margin-top: 10px;
            }
            button {
                width: 100%;
                padding: 10px;
                margin-top: 10px;
                border: none;
                cursor: pointer;
            }
            .indicator {
                display: none;
            }
            .htmx-request .indicator {
                display: inline-block;
            }
            .button-content {
                display: inline-block;
            }
            .htmx-request .button-content {
                display: none;
            }
        """),
    )
)

STORED_CONTENT = ""


@app.get("/")
def home():
    return Titled(
        "Input Demo",
        Div(
            P(f"Stored content: {STORED_CONTENT}", id="content"),
            Form(
                Input(
                    type="text",
                    name="user_input",
                    placeholder="Enter some text",
                    id="user_input",
                    _required=True,
                    minlength=3,
                ),
                Button(
                    Span("Submit", _class="button-content"),
                    Span(
                        "Loading...",
                        aria_busy="true",
                        _class="indicator",
                    ),
                    type="submit",
                    id="submit_button",
                ),
                hx_post="/submit",
                hx_target="#content",
                hx_disabled_elt="#user_input, #submit_button",
            ),
        ),
    )


@app.post("/submit")
async def submit(request):
    global STORED_CONTENT
    form_data = await request.form()
    STORED_CONTENT = form_data.get("user_input", "")
    time.sleep(1)
    return f"Stored content: {STORED_CONTENT}"

The app creates a page with a form with a single text input. Submitting the form sends the contents of the input field to the backend where it is stored in memory (disappears on server restart). I added a few niceties like client-side input validation and input/button disabling, but the main thing is the button loading animation. Here’s how that works:

When the form is submitted, htmx-request gets added to <form>. Using this CSS flips the visibility of the button copy and the bars loading animation

.indicator {
    display: none;
}
.htmx-request .indicator {
    display: inline-block;
}
.button-content {
    display: inline-block;
}
.htmx-request .button-content {
    display: none;
}

By adding hx-disabled-elt="#user_input, #submit_button", disabled="" gets added to both the <input> and the <button>, preventing the content from being modified mid-submit or accidental double submission. Finally, hx-target="#content" replaces the contents of the <p> with the result returned by the server.

A more generic selector approach is documented but it appears to only work for a single selector according to this issue.

I also came across the hx-indicator attribute while trying to figure out a working approach. While initially promising, it seemed redundant because the class htmx-request is already added to the form when it’s submitted and the indicator <img> is nested within the form. I also found this approach also using the htmx-indicator class. Both of these approaches work, but work by toggling the visibility of an indicator loading element only. I wanted to swap the standard copy with an indicator while the request was in flight.

This approach isn’t what I would call easy, but it does get the job done. If you have more experience with htmx and know of a simpler way to accomplish the above, I’d love to hear from you.