Skip to main content
Ben Nadel at FirstMark Tech Summit (New York, NY) with: Atticus White
Ben Nadel at FirstMark Tech Summit (New York, NY) with: Atticus White

Exploring DOM Mutation Observation In Alpine.js 3.13.5

By
Published in

When your document first renders, Alpine.js traverses the DOM (Document Object Model) tree and activates any existing directive bindings. After this initial activation phase, Alpine.js uses the MutationObserver API to listen for subsequent changes to the DOM structure. And, when it observes changes, it will initialize new directive bindings and teardown old directive bindings as needed. Let's take a closer look at these mechanics in action.

Setting an Element's innerHTML

In this first example, we're going to set the innerHTML of an existing element. The new HTML will contain a subtree that has its own x-data binding as well as other Alpine.js directive bindings. To make the code easier to read, I'm storing the "inert HTML" inside a <template> tag. Alpine.js won't examine the DOM tree inside the template; which means that none of the embedded directives will matter on document render.

<!doctype html>
<html lang="en">
<body>

	<button onclick="mutate()">
		Set innerHTML
	</button>

	<article id="target">
		<!-- To be overridden by mutation. -->
		<p> Default content. </p>
	</article>

	<!--
		NOTE: Since this HTML is inside a TEMPLATE, it will NOT be bound by Alpine.js.
		However, once we insert this HTML into the rendered DOM, it will activate.
	-->
	<template id="source">
		<p
			x-data="{ message: 'Hello sunshine!' }"
			x-init="console.log( 'Inert HTML has been activated!' )">
			<span
				x-text="message"
				:style="{ 'background': 'gold' }">
			</span>
		</p>
	</template>

	<script type="text/javascript" src="../vendor/alpine.3.13.5.js" defer></script>
	<script type="text/javascript">

		function mutate() {

			window.target.innerHTML = window.source.innerHTML;

		}

	</script>

</body>
</html>

Now, if we run this Alpine.js code and set the innerHTML, we get the following output:

DOM mutations being observed by Alpine.js.

As you can see, when we set the innerHTML of the target element, the embedded x-data, x-init, x-text, and x-bind:style directives are all "observed" by Alpine.js; and, are bound to the new DOM tree structure.

Setting An Element's Attribute

The MutationObserver sees more than macro changes to the DOM - it can also see low-level attribute changes. In this example, we're going to take an existing Alpine.js component (ie, one that's already associated with an x-data scope binding) and dynamically inject x-text and x-bind:style directives:

<!doctype html>
<html lang="en">
<body>

	<button onclick="mutate()">
		Set Attribute
	</button>

	<article>
		<p x-data="{ message: 'Hello sunshine!' }">
			<!-- Going to dynamically insert attributes here. -->
			<span id="target"> Default content. </span>
		</p>
	</article>

	<script type="text/javascript" src="../vendor/alpine.3.13.5.js" defer></script>
	<script type="text/javascript">

		function mutate() {

			window.target.setAttribute( "x-text", "message" );
			window.target.setAttribute( "x-bind:style", "{ background: 'gold' }" );

		}

	</script>

</body>
</html>

Now, if we run this Alpine.js code and call .setAttribute() a few times, we get the following output:

DOM mutations being observed by Alpine.js.

As you can see, when we dynamically inject the x-text and x-bind:style directives on the existing element, Alpine.js "observes" the changes and binds the directives to the document.

Replacing an Element's innerHTML

When mutating the existing DOM tree, some changes are additive, as we saw in the previous examples; and, some changes are subtractive. Alpine.js observes these subtractive changes as well. And, will teardown / destroy existing directive bindings as necessary.

In the following example, we're going to completely overwrite the innerHTML of an element that contains an Alpine.js x-data directive. The scope associated with this directive contains both init() and destroy() life-cycle methods so that we can see when the scope is created; and when it's destroyed.

<!doctype html>
<html lang="en">
<body>

	<button onclick="mutate()">
		Replace innerHTML
	</button>

	<article id="target">
		<p x-data="{
				date: new Date().toTimeString().slice( 0, 8 ),
				message: 'Hello sunshine!',
				init: () => console.log( 'init() method invoked.' ),
				destroy: () => console.log( 'destroy() method invoked.' ),
			}">
			<strong x-text="date"></strong> &rarr;
			<span x-text="message"></span>
		</p>
	</article>

	<script type="text/javascript" src="../vendor/alpine.3.13.5.js" defer></script>
	<script type="text/javascript">

		function mutate() {

			window.target.innerHTML = window.target.innerHTML;

		}

	</script>

</body>
</html>

Now, if we run this Alpine.js code and swap-out the innerHTML, we get the following output:

DOM mutations being observed by Alpine.js.

As you can see, when we swap-out the innerHTML of the given element, the destroy() life-cycle method on the existing binidng is called. Then, when the new HTML is activated by Alpine.js, the init() life-cycle method on the new binding is called.

DOM Mutations During DOM Mutations

In all of the previous examples, we applied DOM mutations to a "settled" document. These DOM mutations triggered the MutationObserver handler that Alpine.js maintains internally; and, in response, Alpine.js setup and tore-down directives as necessary.

But, Alpine's MutationObserver isn't always watching the DOM tree. For logic and performance reasons, Alpine detaches the mutation observer while it's mutating the DOM. If you look at the x-if directive, for example, you'll see that internally to the effect callback, the x-if directive performs its DOM manipulation inside a mutateDom() operator.

The mutateDom() method detaches the MutationObserver while the x-if injects its associated element. The x-if directive must then call initTree() explicitly in order to have Alpine.js initialize the directive bindings on the new element.

We can see this in action by creating an Alpine.js directive that appends HTML during the x-if operation. To make the interplay more obvious, I've patched the Alpine.js library to log the start/stop operations for the MutationObserver.

<!doctype html>
<html lang="en">
<body>

	<button onclick="Alpine.initTree( window.target )">
		Call `Alpine.initTree()`
	</button>

	<article x-data>
		<template x-if="true">

			<p x-test>
				Injected!
			</p>

		</template>
	</article>

	<!--
		NOTE: Since this HTML is inside a TEMPLATE, it will NOT be bound by Alpine.js.
		However, once we insert this HTML into the rendered DOM, it will activate (well,
		depending on WHEN this is done).
	-->
	<template id="source">
		<p
			id="target"
			x-data="{ message: 'Injected by x-test' }"
			x-init="console.log( 'Init called on injected element.' )"
			x-text="message">
		</p>
	</template>

	<script type="text/javascript" src="../vendor/alpine.3.13.5.js" defer></script>
	<script type="text/javascript">

		document.addEventListener(
			"alpine:init",
			function handleInit() {

				Alpine.directive( "test", TestDirective );

			}
		);

		function TestDirective( element, metadata, framework ) {

			// As part of the constructor logic, we're going to further alter the DOM.
			// However, the "x-if" directive turns-off the mutation observer while the
			// x-if functionality is performing its DOM operations. As such, Alpine won't
			// "see" this new element being injected.
			console.warn( "Injecting some new HTML inside x-test." );
			element.insertAdjacentHTML( "afterend", window.source.innerHTML );

		}

	</script>

</body>
</html>

As you can see, the x-test directive is bound during the x-if evaluation. The x-test directive, in turn, calls insertAdjacentHTML() to alter the DOM. But, the subsequent DOM changes won't be "seen" by Alpine.js until we explicitly call Alpine.initTree(). And, when we do, we get the following output:

DOM mutations being observed by Alpine.js after initTree() is called.

As you can see, the HTML injected by our x-test directive was not "seen" by Alpine.js since we injected while the MutationObserver instance was detached (due to the parent x-if DOM mutation). As such, we had to call initTree() on the inserted HTML in order for Alpine.js to come through and initialize the directives.

Aside: Alpine provides both initTree() and destroyTree() methods for manually setting up and tearing down directives, respectively.

Instead of calling Alpine.initTree(), we could have also performed our DOM mutation inside an Alpine.nextTick() callback. The .nextTick() method invokes the given callback after Alpine.js finishes making its reactive DOM updates.

For the most part, it seems that DOM manipulation in Alpine.js "just works". When you add new directives via DOM manipulation, Alpine.js initializes them. And, when you remove existing directives via DOM manipulation, Alpine.js destroys them. It seems it gets a bit more complicated when you perform DOM manipulation as the result of other DOM manipulations. But, it seems that Alpine.js gives us tools for that as well.

Want to use code from this post? Check out the license.

Reader Comments

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