Using hx-preserve To Persist Elements Across Swaps In HTMX
With HTMX, you can use the hx-boost
attribute to AJAX'ify navigation in your ColdFusion applications. Boosting pages increases the complexity of the application and can lead to some strange JavaScript behaviors. But, this trade-off in complexity ushers in the ability to maintain some state across pages. And one way to maintain state in HTMX is with the hx-preserve
attribute.
Note: While I'm talking about state preservation in the context of a boosted app, the
hx-preserve
attribute applies to all swap operations, not just those managed by thehx-boost
form and link interception.
When HTMX swaps content into a target element within the rendered Document Object Model (DOM), it follows these steps (among many others):
Search the target element for any children that have the
hx-preserve
attribute.Move these preserved elements into a temporary quarantine container (internally referred to as the "pantry" - which is an adorable name).
Prepend the new AJAX-delivered content into the target element.
Remove (and de-init) all of the old content from the target element.
Iterate over all of the preserved elements that have been quarantined in the pantry; and, for each of them, replace them into the newly-injected content by matching against the quarantined element's
id
attribute.
To explore the hx-preserve
mechanics, I've created a two page ColdFusion application in which each page has a button for creating new toast notifications; and, a fixed-position area into which the toasts will be rendered. The toast rendering container will use the hx-preserve
attribute so that it will be maintained as we navigate between the hx-boost
'ed pages.
Here's page one:
<cfoutput>
<h1>
Page One
</h1>
<button hx-post="toast.cfm?from=one">
Make Toast (One)
</button>
<aside id="toaster" hx-preserve>
<!--- Toasts to preserve across AJAX swaps. --->
</aside>
</cfoutput>
And, here's page two. It's essentially identical; but, passes a different from
query-string parameter in the hx-post
URL:
<cfoutput>
<h1>
Page Two
</h1>
<button hx-post="toast.cfm?from=two">
Make Toast (Two)
</button>
<aside id="toaster" hx-preserve>
<!--- Toasts to preserve across AJAX swaps. --->
</aside>
</cfoutput>
As you can see, both of these ColdFusion pages include an <aside>
element with the same id="toaster"
attribute. The hx-preserve
attribute matches elements based on id
, so this consistency across pages is necessary. I would normally put this element into a layout template; but, I was trying to keep this demo as simple as possible.
Both of the pages have a button that posts to toast.cfm
. This ColdFusion API end-point does nothing but disable the normal swap behavior via the HX-Reswap
HTTP header; and return a single out-of-band (OOB) swap directive:
<cfscript>
param name="url.from" type="string" default="unknown";
// Tell HTMX that we don't want to perform any default swap - all swaps in this
// request will be processed as explicit out-of-band swaps.
header
name = "HX-Reswap"
value = "none"
;
</cfscript>
<cfoutput>
<!--- Take the innerHTML of this element and append it to the toaster. --->
<div hx-swap-oob="beforeend:##toaster">
<div data-from="#encodeForHtmlAttribute( url.from )#">
From #encodeForHtml( url.from )# [#createUniqueID( "counter" )#]
-
<button
hx-get="null.cfm"
hx-trigger="click, load delay:5s"
hx-target="closest div"
hx-sync="this:drop">
X
</button>
</div>
</div>
</cfoutput>
The beforeend:#toaster
OOB value tells HTMX to append the new div (toast notification) to the hx-preserve
ed toaster container on the client. And, the load delay:5s
trigger on the button tells HTMX to automatically remove the toast notification after 5 seconds.
If we run this ColdFusion application, trigger some toast notifications, and navigate between the two hx-boost
'ed pages, we can see that the state of the toast container is preserved:

Since this is a boosted application, the entire content of the <body>
is being swapped as I navigate between page One and page Two. However, as part of the swapping process, HTMX is preserving the toaster contents; which is why the toast notifications aren't being cleared the way they might in a traditional multi-page application (MPA).
Aside: I'm not demonstrating it in this post, but in the GIF above you might have noticed that there's a third page with no toaster container. If I were to navigate to this third page, the existing toasts would immediately disappear—there's no corresponding
hx-preserve
id
to swap-in. But, if I were to then click the Back button, HTMX would re-render the cached version of the toast notifications; and, it would reset theload delay:5s
timer on each of re-rendered toasts.
One final thing to call-out. Each toast notification has a button to remove the corresponding notification. This button performs an hx-get
to null.cfm
. This is a curious tip that I picked up in an article that I read (though unfortunately I can't find the reference). The null.cfm
page does nothing but get cached and perform a delete
swap:
<cfheader
name="HX-Reswap"
value="delete"
/>
<cfheader
name="Cache-Control"
value="max-age=604800"
/>
This is a utility end-point that gives HTMX (and you) a round-about way to remove an element without adding any additional JavaScript or event bindings. Basically, you call this end-point and any element that you hx-target
will be removed from the DOM.
I could have also used the htmx.remove()
method to remove the notification—it takes a delay argument. The following two notification setups would be roughly the same (for all intents and purposes):
<!--- Our original, with 'null.cfm' mechanics. --->
<div data-from="#encodeForHtmlAttribute( url.from )#">
From #encodeForHtml( url.from )# [#createUniqueID( "counter" )#]
-
<button
hx-get="null.cfm"
hx-trigger="click, load delay:5s"
hx-target="closest div"
hx-sync="this:drop">
X
</button>
</div>
<!--- Using the htmx.remove() call with more logic. --->
<div
hx-on:htmx:load="htmx.remove( this, 5000 )"
data-from="#encodeForHtmlAttribute( url.from )#">
From #encodeForHtml( url.from )# [#createUniqueID( "counter" )#]
-
<button hx-on:click="htmx.remove( this.parentElement );">
X
</button>
</div>
The latter doesn't require a (subsequently cached) HTTP request to trigger the removal; but, it does require more imperative logic. I think both ways are worth considering.
In any case, the hx-preserve
attribute looks like it will be a great tool to have in the toolbox.
Preserving Audio/Video Players
The hx-preserve
attribute doesn't work perfectly with all types of HTML elements. For example, video and audio players have been a point of friction for things like this in the past (a few years ago, I explored a persisted video play in Hotwire Turbo). The HTMX framework attempts to use a modern API for moving elements without losing state, Element.moveBefore()
, if it's available; but, in context in which that can't be used, the documentation suggests either using hx-history-elt
to change the page-swap boost root; or, use the morph-dom extension.
To be clear, I've never tried either of those approaches (in HTMX) - this is just what I've read.
Want to use code from this post? Check out the license.
Reader Comments
Ben,
I've been enjoying your HTMX content. I look forward to have a few moments to digest your thought process and integrate it into my thinking with HTMX.
Keep it up!
I'd be interested in seeing some content on SSE with HTMX or WebSocket's. Pusher App in particulate.
I've playing the the HTMX JavaScript API with these concepts and at first blush it did not work how I thought it would.
I know you have an cfml SSE example a few year's back and I like to see that updated for htmx and ofcouse pusher if a great tool that just works (until lucee / cfml had better native WebSocket support)
Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →