How to Update Multiple Page Elements from One htmx Request
A button submits code, tests run, feedback appears. Standard htmx. But the submissions dropdown stays stale; the new submission is in the database, just not in the dropdown. One request, two elements to update.
The problem: one request, two things to update
On our Rust platform, each exercise page has a "Run Tests" button. It posts the editor code to a Django view, which compiles and runs the tests, then swaps a pass/fail panel into a #feedback div.
Next to the editor there is a dropdown of your past submissions. Run the tests, and a new submission gets saved server-side. But the dropdown stayed stale until you reloaded the page. The new submission was there in the database, just not in the <select>.
So now I have one request that needs to update two unrelated parts of the page: the feedback panel (the htmx target) and the submissions dropdown (somewhere else entirely in the DOM).

First instinct: write JavaScript to read the response, build a new <option>, prepend it to the select. That works until you remember the dropdown also enforces a max number of submissions, drops duplicates, and orders newest-first. Replicate that logic in the browser and you now have two sources of truth that drift apart the first time you change the server rule.
Htmx out-of-band swaps
htmx has a feature for exactly this: out-of-band swaps.
The hx-swap-oob attribute allows you to specify that some content in a response should be swapped into the DOM somewhere other than the target, that is "Out of Band". This allows you to piggyback updates to other element updates on a response.
htmx swaps any element carrying hx-swap-oob into its matching target on the page, separately from the main swap. One response, many updates.
First I pulled the submissions dropdown options into a partial so the page and the view render them identically:
<!-- _submission_options.html -->
<option disabled selected>Submissions / Reset</option>
{% for submission in submissions %}
<option value="{{ submission.unique_hash }}">{{ submission.created_at|date:"Y-m-d H:i" }} {% if submission.ok %}(OK){% else %}(Failed){% endif %}</option>
{% endfor %}
<option value="reset">Reset</option>
The page includes it inside the <select id="submissions">. The view renders the same partial and tags it for an out-of-band swap:
from django.template.loader import render_to_string
from django.utils.html import escape
def validate(request):
# ... run the tests, save the submission ...
submissions = Submission.objects.filter(
exercise=exercise, user=user
).order_by("-created_at")
options = render_to_string(
"_submission_options.html", {"submissions": submissions}
)
return HttpResponse(
f"""
<div class="...">{message}</div>
<pre>{escape(output)}</pre>
<div hx-swap-oob="innerHTML:#submissions">{options}</div>
"""
)
The first part of the response swaps into #feedback as usual. htmx spots the hx-swap-oob element, pulls it out, and applies it to #submissions instead. The button HTML only knows about #feedback. The view decides what else to update. Whoever owns the data controls how it renders.
A second example: progress bars that update themselves
On the Python platform the same trick drives the learning-path progress widget. Passing an exercise recomputes your progress along every path it belongs to and swaps the bars into a sidebar, from the same request that renders the pass/fail panel:
if ok:
paths_html = ""
for path in bite.bite_paths.prefetch_related("bites"):
# ... compute completed / total / pct for this path ...
paths_html += render_progress_bar(path, completed, total, pct)
extra_html += (
f'<div id="learning-paths-progress" hx-swap-oob="innerHTML">{paths_html}</div>'
)
The two examples aim at their targets differently. The progress widget uses a bare hx-swap-oob="innerHTML": htmx swaps the fragment into whatever element already shares its id. The dropdown uses the selector form, hx-swap-oob="innerHTML:#submissions", so the carrier <div> can target the <select id="submissions"> without needing to share its id.
Use innerHTML instead of the default hx-swap-oob="true". true replaces the whole element (its outerHTML), which for the <select> throws away the htmx listener attached to it. innerHTML keeps the element and swaps only its children, so the listener survives and the options refresh underneath it.
Here's the progress bars before and after passing an exercise. The bars update via out-of-band swap from the same response that renders the pass/fail feedback:


You might spot the submissions dropdown in these shots and wonder why it is not OOB-swapped here too, like on the Rust platform above. It is the boundary of the technique: OOB swap is solid when you replace an element's whole inner content (innerHTML:#submissions), but inserting a single new <option> means wrapping it in a <template> so the browser does not strip it, and htmx 2.x handled that case unreliably. So here the one-option insert runs through a small htmx:afterSwap listener instead. The view writes the new submission into a hidden div, and the listener reads it and prepends the <option>:
document.addEventListener("htmx:afterSwap", () => {
const newSub = document.getElementById("new-submission");
if (!newSub) return;
const select = document.getElementById("submissions");
const opt = document.createElement("option");
opt.value = newSub.dataset.hash;
opt.textContent = newSub.dataset.label;
select.insertBefore(opt, select.children[1]); // after the placeholder
select.value = opt.value;
});
One honest cost: this adds a query. After saving, the view re-fetches the submissions to render the partial. The alternative is re-implementing state changes in pure JavaScript, creating behavior in two places. With out-of-band swaps you drive the logic from the view, all in one place. One more query, but less code and a more maintainable solution.
Whoever fetches the data should render it. Keep the query and the template together.
For more on hypermedia-driven applications, see this great book: Hypermedia Systems.
AI is an accelerator, not a compass. I coach developers to ship faster and understand what they ship. See the path →