Skip to main content
Ben Nadel at the MySQL NYC Meetup (Oct. 2024) with: David Baird
Ben Nadel at the MySQL NYC Meetup (Oct. 2024) with: David Baird

What Happens When You Mutate The DOM Outside Of HTMX

By
Published in Comments (2)

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:

  1. On node initialization, it adds the class, active, to the parent element.

  2. 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 &rarr; hx-get
	</button>
	<button
		hx-ext="my-button"
		hx-on:click="this.remove()"
		class="b2">
		Remove Me &rarr; this.remove()
	</button>
	<button
		hx-ext="my-button"
		hx-on:click="htmx.swap( this, '', { swapStyle: 'outerHTML' } )"
		class="b3">
		Remove Me &rarr; 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:

Screen recording of the browser as each button is being clicked.

The result of the button clicks:

  1. After clicking the first button, the cyan background is removed from the parent container. This is because we're removing the button via an hx-get swap. HTMX manages this change; and therefore, knows to trigger the htmx:beforeCleanupElement event on the DOM.

  2. After clicking the second button, the cyan background remains in place. This is because we're removing the button via the native Element.remove() API. HTMX has no idea that this change is occurring; and therefore, doesn't know to trigger the htmx:beforeCleanupElement event on the DOM.

  3. 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 the htmx.swap() API call), HTMX knows to trigger the htmx: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

16,020 Comments

@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

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel