What Happens When You Mutate The DOM Outside Of HTMX
In a previous post, I looked at using Alpine.js inside an HTMX application. Since Alpine.js uses the MutationObserver
API to observe changes within the Document Object Model (DOM) structure, it doesn't much matter where mutations are initiated. HTMX, however, isn't quite so dynamic. As such, I wanted to sanity check what happens when DOM mutations are initiated outside of the HTMX life-cycle.
Before we look at any code, I want to underscore that this is an important mechanic to understand because a misunderstanding could lead to memory leaks and other funky states in a boosted application. If every page is a full-refresh, cleaning up after one's self is less of an issue. However, if we use the hx-boost
attribute to create a long-lived page process, performing DOM mutations outside of the HTMX life-cycle could lead to event-bindings (and other DOM mutations) that never get removed from the DOM.
With that said, to sanity check this I created an HTMX extension, my-button
, that does two things:
On node initialization, it adds the class,
active
, to the parent element.On node cleanup, it removes the class,
active
, from the parent element.
This class, active
, changes the background color of the parent element to cyan
to make the change more obvious. I then created three buttons within this parent element, each with the my-button
extension; and, I programmed each click
event to remove the target element. This way, we can see which buttons "see" the HTMX clean-up workflow.
Here's my demo code:
<p class="box">
<button
hx-ext="my-button"
hx-get="null.cfm"
hx-swap="outerHTML"
class="b1">
Remove Me → hx-get
</button>
<button
hx-ext="my-button"
hx-on:click="this.remove()"
class="b2">
Remove Me → this.remove()
</button>
<button
hx-ext="my-button"
hx-on:click="htmx.swap( this, '', { swapStyle: 'outerHTML' } )"
class="b3">
Remove Me → htmx.swap(this)
</button>
</p>
<script type="text/javascript">
htmx.defineExtension(
"my-button",
{
/**
* I handle events for this extension.
*/
onEvent ( name, event ) {
// HTMX events are triggered on this extension for EVERY event that
// bubbles up from a descendant point in the DOM tree. As such, we always
// have to take care that the events are relevant to this extension.
if ( ! this.isRelevant( event ) ) {
return;
}
// This is a test to see if HTMX "sees" DOM activity that it doesn't
// initiate (via swaps). The extension will add / remove an "active" class
// to the parent element so that we can see which DOM mutations sneak by
// htmx.
switch ( name ) {
case "htmx:afterProcessNode":
case "htmx:beforeCleanupElement":
htmx.toggleClass( event.detail.elt.parentElement, "active" );
break;
}
},
/**
* I determine if the given event is relevant to this extension.
*/
isRelevant ( event ) {
return ( event.detail?.elt?.getAttribute( "hx-ext" ) === "my-button" );
}
}
);
</script>
Now, let's see what happens when we click each button. In the following GIF, note that I am refreshing the page in between each click to manually reset the state of the demo:

The result of the button clicks:
After clicking the first button, the
cyan
background is removed from the parent container. This is because we're removing the button via anhx-get
swap. HTMX manages this change; and therefore, knows to trigger thehtmx:beforeCleanupElement
event on the DOM.After clicking the second button, the
cyan
background remains in place. This is because we're removing the button via the nativeElement.remove()
API. HTMX has no idea that this change is occurring; and therefore, doesn't know to trigger thehtmx:beforeCleanupElement
event on the DOM.After clicking the third button, the
cyan
background is removed from the parent container. This is because we're using the HTMX JavaScript API to swap-out the button. Since HTMX is managing this change (internally to thehtmx.swap()
API call), HTMX knows to trigger thehtmx:beforeCleanupElement
event on the DOM.
The important take-away here is that HTMX does not trigger any events if it's not the one initiating the DOM mutation. Unlike Alpine.js, which sees all thanks to the MutationObserver
API, HTMX only sees that which it controls. As such, we have to be careful about setting state that depends on "teardown" events for clean-up. If we start removing DOM nodes via client-side JavaScript, we may end up leaving unexpected artifacts in place on a long-lived page.
Want to use code from this post? Check out the license.
Reader Comments
Hi Ben,
I love your voyage of discovery by experimentation with HTMX.
I am learning how to do this from you.
I have created an index of your articles here to help me keep track of lessons learned.
https://www.blog.ajabbi.com/2025/05/ben-nadel-on-htmx.html
Thanks and don't stop 😀😀😀
Mike Peters
New Zealand
@Mike,
Very cool! 🙌 I'm glad someone else is finding this stuff interesting. HTMX feels like it has a lot of potential. Right now, I'm trying to wrap my head around modal window / fly-out kind of stuff. I think that will be the biggest hurdle both mentally and technically. But, for the most part, all of this stuff is a lot of fun to think about.
Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →