Skip to main content
Ben Nadel at cf.Objective() 2012 (Minneapolis, MN) with: Ryan Anklam
Ben Nadel at cf.Objective() 2012 (Minneapolis, MN) with: Ryan Anklam

Using Alpine.js In HTMX

By
Published in , Comments (1)

The HTMX JavaScript framework allows us to move a lot of state management out of the browser and back into the ColdFusion server where the "source of truth" resides. But, not all interactions—and not all state—need to be sourced from the server. In such cases, we can use Alpine.js to provide light-weight state management and event binding in the browser. That said, both HTMX and Alpine.js want to "manage the DOM" and the event-bindings. As such, it's not obvious that they'll play well together. To get a sense of where the points-of-friction might lie, I wanted to put together a small demo that uses both HTMX and Alpine.js as well as the hx-boost attribute to "AJAX'ify" the page navigation.

This HTMX demo has two top-level ColdFusion pages. But, only the first one has any value - the second page only exists to allow us to explore the effects of the browser's Back Button on the life-cycle of the page state.

The first page does two things:

  1. It's uses an HTMX-powered button to lazily load (or reload) a partial into the current page. This partial will contain some additional Alpine.js logic.

  2. It uses the Alpine.js x-data attribute to setup an outer counter.

<style type="text/css">
[x-data] {
border: 2px solid red ;
& [x-data] {
border-color: darkcyan ;
}
}
</style>
<cfoutput>
<h1>
Page One
</h1>
<button
hx-get="more.cfm"
hx-target="next">
Refresh Alpine Content
</button>
<section x-data="{ outerCounter: 0 }">
<!--- To be loaded dynamically using previous button. --->
</section>
</cfoutput>
view raw index.cfm hosted with ❤ by GitHub

When the user clicks the button, HTMX will make a request to more.cfm and swap the contents into the <section> element. This partial sets up another counter and provides a button that increments both the outer counter, inherited from the parent page, and the inner counter defined in the partial.

Note that in this partial context, the Alpine.js component state is being setup by a function, InnerController(), rather than an inline x-data object literal. This isn't strictly necessary; but, I wanted to log the setup / teardown of the component and I rather dislike defining functions within an HTML element attribute.

<script>
// NOTE: This controller _definition_ is globally scoped. Which means every time this
// ColdFusion template is fetched by HTMX, HTMX will re-evaluate this script tag and
// the previous _definition_ is overwritten. But, this doesn't impact the _instance_
// of the controller that has already been instantiated and bound to the DOM.
function InnerController() {
return {
innerCounter: 0,
init: () => console.log( "Inner section init." ),
destroy: () => console.log( "Inner section destroy." )
};
}
</script>
<section x-data="InnerController">
<dl>
<dt>Outer Counter (inherited)</dt>
<dd x-text="outerCounter"></dd>
<dt>Inner Counter (local)</dt>
<dd x-text="innerCounter"></dd>
</dl>
<button @click="outerCounter++; innerCounter++;">
Increment Counters
</button>
</section>
view raw more.cfm hosted with ❤ by GitHub

Alpine.js works by setting up a MutationObserver on the document and literally watches for any and all changes being applied to the structure of Document Object Model (DOM) tree. As such, when HTMX loads—or reloads—a portion of the page, a mutation will be triggered and Alpine.js will know about it. This gives Alpine.js the ability to hook into the life-cycle of the DOM even if the DOM is predominantly being controlled by HTMX.

To see this in action, let's try loading the partial and then incrementing the counters:

There's several key take-aways in this animation:

  1. Alpine.js is able to bind all of the Alpine.js directives on the DOM tree branch that is loaded by HTMX. This includes the @click handler on the button as well as the x-text and x-data attributes.

  2. When I re-load the partial using HTMX, Alpine.js is able to hook into the life-cycle of the partial DOM; and, can trigger both the destroy and init callbacks on the bound controller.

  3. When the partial reloads, the inner counter is reset to 0. This is because it is being bound to a new instance of InnerController(). The outer counter, however, which is bound to the top-level page, is maintained across partial reloads.

This HTMX and ColdFusion demo is also using hx-boost to AJAX'ify the page navigation. Here is a truncated version of what the layout template looks like:

<cfoutput>
<!doctype html>
<html lang="en">
<head>
<!--- ... truncated ... --->
<script src="/shared/alpine-3.14.9.min.js" defer></script>
<script src="/shared/htmx-2.0.4.js"></script>
</head>
<body hx-boost="true" hx-sync="this:replace">
<!--- ... content ... --->
</body>
</html>
</cfoutput>
view raw _layout.cfm hosted with ❤ by GitHub

As you can see, we have the hx-boost on the body element. Now, we can see how the Alpine.js state is managed if we navigate to the second top-level page and then use the browser button to return to the first page:

Here, we can see our first real point of friction in using Alpine.js in an HTMX powered application. When we go back to the first page, the structure of the DOM tree is maintained (since it was snapshotted into the HTMX cache); but, the state of the Alpine.js components were reset. Or rather, Alpine.js sees the DOM structure changes (thanks to the MutationObserver) caused by the back button; and, in response, it re-initializes all of the x-data components. As such, it resets its own internal state, so to speak.

This state reset / re-initialization, however, is a byproduct of the fact that we're using hx-boost to AJAX'ify our navigation. If we remove hx-boost from the body and then re-run the demo, we get a very different outcome:

This time, without hx-boost intercepting the requests, we rely on the browser's native page cache. And, when we navigate to the second page of the ColdFusion demo and then use the Back button to return the first page, the Alpine.js state is maintained. In fact, we can continue to increment both counters right where we left off.

But, there is something weird going on. If you look at the console logging, we can see that the init() method is still be called on the Alpine.js component when we hit the back button. But, the newly-instantiated controller doesn't seem to be getting bound to the DOM—if it were, the inner counter would be getting reset.

To be honest, I don't understand the mechanics of what is happening in this case. In fact, if I try to reset the inner counter inside the init() callback, it has no effect on the DOM rendering during the Back button. So, it's almost like Alpine.js or the browser is just disregarding the new state and using the one cached in the DOM.

So, I think what we're seeing here is that HTMX and Alpine.js can work well together; but, the degree of cooperation depends on whether or not you're using hx-boost; and, in just how much state is actually being managed by Alpine.js.

That said, this was my first attempt at using both frameworks together in a ColdFusion application. So, consider this a naive exploration.

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

Reader Comments

Post A Comment — I'd Love To Hear From You!

Markdown formatting: Basic formatting is supported: bold, italic, blockquotes, lists, fenced code-blocks. Read more about markdown syntax »
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.
Cancel
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