ColdFusion And HTMX Contact App
A couple of weeks ago, I read Hypermedia Systems by Carson Gross. The book is an introduction to HTMX - a JavaScript library that extends the HTML specification and brings dynamic single-page application (SPA) type features to a traditional multi-page application (MPA) architecture. Some of the aspects of HTMX are fairly simple; but, some require a sizable mindset shift. As such, I thought it would be helpful (for me) to take the "contacts app" that Carson builds in the book and translate it into a ColdFusion application context.
View this code in my ColdFusion + HTMX Contacts App project on GitHub.
In the book, Carson starts out with a vanilla HTML application. Then, iteratively layers-in one HTMX capability at a time. The book is very well written; and it illustrates the HTMX features quite effective. In my ColdFusion version, I try to take the same path laid out in the book; but, I do diverge from Carson's implementation from time to time. Essentially, I follow the same spiritual steps; but, my personal journey is slightly different due to a variety of subjective choices.
As I was putting my GitHub repository together, I didn't know if I wanted to implement each iteration as a branch or as a separate directory. In the end, I decided to make each iteration it's own directory (with some code shared across all the iterations). I felt that this made it easier to explore - I wasn't having to constantly git checkout
different branches to jump between the various implementations.
The downside of making each iteration its own directory is that you can't simply git diff
to see what changed in a given iteration. As such, I gave each iteration it's own README.md
file to help highlight what I was attempting to do in the given iteration. It leaves a lot to be desired from an educational standpoint; but, seeing as this effort was primarily for my own learning, I accepted the trade-off.
What follows is just a replication of the various README.md
files from each iteration.
v1 - The Vanilla ColdFusion Application
This iteration of the ColdFusion + HTMX application provides the pre-HTMX baseline. It's just a vanilla ColdFusion application that provides simple CRUD (Create, Read, Update, Delete) operations on a list of contacts.
See relevant chapter in Hypermedia Systems.
v2 - Initial HTMX Version With hx-boost
This iteration of the ColdFusion + HTMX application installs the htmx JavaScript library and adds hx-boost
to the <body>
tag. This directive automatically intercepts the link and form posts triggered within the current page and implements them via AJAX, swapping the entire contents of the page (less the <head>
) with the ColdFusion server response.
Above and beyond what happens in the book, I also had to override the error handling configuration. In this version of the application, I'm returning 422
status codes (Unprocessible Entity) for model validation errors. By default, htmx ignores non-2xx responses; which means that, by default, our 422
responses were being ignored. I had to explicitly tell htmx to execute a swap
on 422
:
htmx.config.responseHandling = [ | |
{ code: "204", swap: false }, | |
{ code: "[23]..", swap: true }, | |
// Adding this one to allow 404 responses to be merged into the page. | |
{ code: "404", swap: true, error: true }, | |
// Adding this one to allow 422 responses to be merged into the page. | |
{ code: "422", swap: true }, | |
{ code: "[45]..", swap: false, error: true }, | |
{ code: "...", swap: true } | |
]; |
I've also added a configuration to tell it to swap on 404
(Not Found) errors. This isn't immediately relevant in this application. However, if you view a contact in a new browser tab, and then delete that contact, attempting to view the contact in the original browser tab will result in a 404
response. I think it makes sense to render this error to the page.
See relevant chapter in Hypermedia Systems.
v3 - Adds hx-delete
To The Delete Button
This iteration of the ColdFusion + HTMX application adds the hx-delete
action attribute to the Delete Contact button on the Edit page. This removes the need to have the button wrapped in a <form>
tag; and, turns the button itself into a hypermedia control.
This is the feature of HTMX that I care for the least. Using the DELETE
method makes working with ColdFusion harder. In CFML, it's easy to differentiate between a GET
and a POST
because they populate two different scopes: url
and form
, respectively. As such, including submitted
flags and verifying XSRF (Cross-Site Request Forgery) tokens, for example, is fairly easy.
Once we introduce the DELETE
verb, life gets harder. With a DELETE
request, there is currently no request body. Which means that all form values are submitted in the URL and therefore populate the url
scope. As such, if we wanted to check for something like a submitted
flag, we'd have to do so in the url
scope (not the traditional form
scope). This, in turn, makes the attack surface a little easier to access.
Now, the counter-argument to this concern is that forms don't currently support DELETE
; so, such a request would have to be made via JavaScript; and, a properly configured CORS (Cross Origin Resource Sharing) setting should prevent a malicious off-site DELETE
request. But, this feels like a coincidental alleviation, not an intentional one. After all, if a browser were to introduce method="DELETE"
support, then suddenly this would be an issue.
To ensure the proper HTTP request, I'm now having to explicitly check to see if the incoming request was performed via DELETE
. This is doable; but, it requires more code than simply checking to see if a value was populated in the form
scope.
If we had a more robust router in the demo app - one that would only route DELETE
requests here - then that might be less of an issue. But, in my app, I use a common post-back pattern; which means that the delete-contact page is also capable of rendering a form (and should also respond to a GET
request).
Another issue with this is that custom hypermedia controls don't play well with hx-boost
, which we added in the previous iteration. As such, we have to add hx-target
and hx-push-url
attributes to the button. Ultimately, our button ends up looking like this:
<button | |
hx-delete="delete.cfm?id=#contact.id#&submitted=true" | |
hx-target="body" | |
hx-push-url="true" | |
hx-confirm="Are you sure you want to delete this contact?" | |
type="button"> | |
Delete Contact | |
</button> |
Long story short, this is not a real value-add feature of the HTMX framework for me. I understand how it makes sense academically; but, I don't feel like it makes sense practically in the way I think about application development. Frankly, I think it makes the code more complicated and cuts against the grain of how CFML wants to work.
Aside: To see how the
hx-delete
works with error responses, I've gone back and added logic to prevent deletes on any contact whose first name is "Diva" (ex, "Diva Jones"). Since this kind of a test generally makes sense, I've added it to the previous iterations as well.
See relevant chapter in Hypermedia Systems. It's the section labeled, "A Second Step: Deleting Contacts With HTTP DELETE".
v4 - Adds hx-get
Realtime Email Validation
This iteration of the ColdFusion + HTMX application adds the realtime email validation to the Add / Edit contact form. It does this by including an hx-get
on the email input field which hits a new endpoint specifically created for email validation.
Since the realtime validation works by replacing the contents of a <span>
on the page, it works by returning an empty string if the given email is available; or, an error string if the email is already taken by another contact. In both cases, the response HTML of the validation request is transcluded into the rendered page.
Here's the updated form field for the email:
<p class="form-field"> | |
<label for="email"> | |
Email: | |
</label> | |
<input | |
id="email" | |
type="text" | |
name="email" | |
value="#encodeForHtmlAttribute( form.email )#" | |
hx-get="validateEmail.cfm" | |
hx-trigger="input delay:200ms" | |
hx-target="next .inline-error" | |
/> | |
<span class="inline-error"> | |
<!--- Populated by the HTMX call above. ---> | |
</span> | |
</p> |
Aside: In future iterations, I added
hx-sync="this:replace"
on the input to quarantine thehx-get
request to the input. However, I didn't go back and add it to the previous iterations.
In the book, they use the change
event within the hx-trigger
attribute. But, I don't quite understand this choice. As such, I've opted to use the input
event instead as I believe it accomplishes the same outcome with less complexity.
I've opened a discussion on the GitHub forum to ask about this input
vs. change
event selection. I've seen this pattern (using the change
event) used in more than just this book, even for realtime validation. As such, I wonder if there's some mechanics that I'm just not understanding at this time.
Update: In the GitHub discussion, I was told that using the
input
is fine; and that theinput
event is being used more frequently in the HTMX documentation now.
On the add contact page, the validation URL doesn't include a contact ID since we're adding a new contact. However, on the edit page, I'm including the current contact ID in the hx-get
attribute so that I can ignore email conflicts on the given record:
Add:
hx-get="validateEmail.cfm"
Edit:
hx-get="validateEmail.cfm?id=#contact.id#"
Aside: I originally tried using the
hx-include
attribute to include the rest of the form inputs along with thehx-get
request. However, on the edit page, the contact ID is being defined in the form'saction
attribute; and parameters encoded into theaction
attribute don't seem to get included by thehx-include
directive.
As an aside, this inline error rendering felt like a good place to progressively enhance with the :has()
CSS selector. In my approach, the <span class="inline-error">
starts out as an inline element with no margins and no inherent size. However, if it contains any other element, I'm using :has(*)
to change the span to a block element with some top margin:
.inline-error { | |
color: red ; | |
font-size: 80% ; | |
} | |
.inline-error:has(*) { | |
display: block ; | |
margin-top: 0.5rem ; | |
} |
Then, in my email validation endpoint, the error message - if any - is returned in another span:
<span class="inline-error-content"> | |
This email address is already taken. | |
</span> |
For browsers that don't support :has()
, this is just two inline span tags. However, for browsers that do support :has()
, the error message will be rendered as a block element with some additional breathing room.
See relevant chapter in Hypermedia Systems. It's the section labeled, "Next Steps: Validating Contact Emails".
v5 - Adds Pagination To Contacts List
This iteration of the ColdFusion + HTMX application adds basic pagination to the list of contacts. The pagination is driven by a page
and pageSize
parameters. So as not to break the previous iterations of the application (which did not using pagination), I've created a new search method on the model: getBySearchPagination()
. It is not that robust; and merely makes sure that you don't go beyond the bounds of the results.
Since the application is already being augmented via hx-boost
, there's nothing else to do to get this to feel snappy. I just added the prev/next links:
<div class="pagination"> | |
<cfif pagination.hasPrevious> | |
<a href="index.cfm?q=#encodeForUrl( url.q )#&page=#( pagination.page - 1 )#&pageSize=#( pagination.pageSize )#"> | |
← <u>Prev</u> | |
</a> | |
<cfelse> | |
← Prev | |
</cfif> | |
<cfif pagination.hasNext> | |
<a href="index.cfm?q=#encodeForUrl( url.q )#&page=#( pagination.page + 1 )#&pageSize=#( pagination.pageSize )#"> | |
<u>Next</u> → | |
</a> | |
<cfelse> | |
Next → | |
</cfif> | |
</div> |
See relevant chapter in Hypermedia Systems. It's the section labeled, "Another Application Improvement: Paging".
v6 - Adds A "load more" Button To The Contacts List
This iteration of the ColdFusion + HTMX application updates the basic pagination of the contacts list to include a "load more" button at the bottom of the list. The load more button will use the existing pagination mechanics; and will take the new <tr>
nodes returned in the paged response and append them to the table:
<cfif pagination.hasNext> | |
<tr> | |
<td colspan="4"> | |
<button | |
hx-get="index.cfm?q=...." | |
hx-target="closest tr" | |
hx-swap="outerHTML" | |
hx-select="tbody > tr" | |
type="button" | |
class="load-more"> | |
Load More | |
</button> | |
</td> | |
</tr> | |
</cfif> |
Notice that the hx-target
is the closest tr
- it will replace the entire row that contains the button itself. And, the content that it injects is the hx-select="tbody tr
.
I don't love that the "load more" button is physically inside the table - this doesn't feel like a semantically correct pattern to me. But, keeping the button in the table makes it easy to both add new rows and to conditionally stop showing the button when there are no more rows to show. Perhaps when I learn more about OOB (out of band) swaps, I'll be able to handle this more elegantly.
I'm leaving the prev/next functionality in place to make this change additive.
See relevant chapter in Hypermedia Systems. It's the section labeled, "Click To Load".
v7 - Adds Infinite Scrolling
This iteration of the ColdFusion + HTMX application updates the "load more" button at the bottom of the list to use the revealed
synthetic event. This way, any time the button is in the browser viewport, it will automatically trigger a load of additional rows. This gives us an "infinite scroll" experience with almost no changes to the core mechanics of the pagination.
All I had to do was add the hx-trigger="revealed"
to the existing button. I also changed the text to read "Loading more..." since the user should never actually have to click on it.
<button | |
hx-get="index.cfm?q=....." | |
hx-trigger="revealed" | |
hx-target="closest tr" | |
hx-swap="outerHTML" | |
hx-select="tbody > tr" | |
type="button" | |
class="load-more"> | |
Loading More.... | |
</button> |
Again, I'm leaving the prev/next functionality in place to make this change additive.
See relevant chapter in Hypermedia Systems. It's the section labeled, "Infinite Scroll".
v8 - Adds Realtime Search
This iteration of the ColdFusion + HTMX application adds realtime search functionality to the contacts list. In addition to the existing click-based form submission (which continues to work even if the HTMX library doesn't load), the contact list results will now be proactively updated as the user types in the search input.
I've removed the "infinite scroll" functionality since I wanted to make sure that the prev/next pagination would work in conjunction with this update.
And, speaking of the prev/next pagination, this demo works by swapping out just the .search-results
portion of the page. The prev/next links, however, sit outside this container; and, have to be swapped using an OOB (Out of Band) selection. In the end, here's the new input element:
<input | |
id="search" | |
type="text" | |
name="q" | |
value="#encodeForHtmlAttribute( url.q )#" | |
hx-get="index.cfm" | |
hx-push-url="true" | |
hx-trigger="input delay:200ms" | |
hx-target=".search-results" | |
hx-select=".search-results" | |
hx-select-oob="pagination" | |
/> |
When HTMX makes this request, it will select the .search-results
out of the AJAX response and merge them into the .search-results
(using innerHTML
) that is already rendered on the page. It will also swap the #pagination
out of the AJAX response and merge it into the #pagination
(using outerHTML
) that is already rendered on the page.
Note that the the hx-select-oob
has some tighter constraints. First, it must be id-driven - it can't use a generic CSS selector like other HTMX attributes. And second, its default swap strategy is outerHTML
, not innerHTML
; though, this can be overridden in the hx-select-oob
attribute value.
See relevant chapter in Hypermedia Systems. It's the section labeled, "Adding Active Search".
v9 - Adds request.htmx
Struct Based On HTTP Headers
This iteration of the ColdFusion + HTMX application adds the request.htmx
struct to the request processing. When HTMX makes a request to the server, it will include a subset of HTTP headers that are relevant to the current interaction. I'm setting these up in the onRequest()
event handler:
component { | |
public void function onRequest( required string scriptName ) { | |
request.htmx = { | |
boosted: ( safelyGetHeader( "HX-Boosted" ) == "true" ), | |
currentUrl: safelyGetHeader( "HX-Current-URL" ), | |
historyRestoreRequest: safelyGetHeader( "HX-History-Restore-Request" ), | |
prompt: safelyGetHeader( "HX-Prompt" ), | |
request: ( safelyGetHeader( "HX-Request" ) == "true" ), | |
target: safelyGetHeader( "HX-Target" ), | |
triggerName: safelyGetHeader( "HX-Trigger-Name" ), | |
trigger: safelyGetHeader( "HX-Trigger" ) | |
}; | |
} | |
} |
These values can then be used to conditionally render smaller partials for optimized database access and over-the-wire latency. My little app, however, isn't really setup to have this kind of differentiation. As such, I'm adding the above struct, but I'm not consuming it in any way at this time.
See relevant chapter in Hypermedia Systems. It's the section labeled, "HTTP Request Headers In Htmx".
v10 - Adds A Loading Indicator During Search
This iteration of the ColdFusion + HTMX application adds a request indicator to the contact list search form to indicate that a request is currently in-flight. These busy indicators are a first-class citizen of HTMX and can be managed through the htmx-indicator
CSS class and / or the hx-indicator
attribute.
The hx-indicator
attribute uses a CSS selector to point to the element (or elements) to be treated as the request indicators. During the in-flight request workflow, HTMX uses special CSS classes to change the visibility (opacity) of the request indicator. The opacity-based styles are automatically injected by the HTXM library; but, you can override the CSS properties in your own CSS if you need to.
For this iteration, I've added a Loading...
span after the submit button. Here's part of my search form:
<form method="get" class="linear-form"> | |
<label for="search"> | |
Search contacts: | |
</label> | |
<input | |
id="search" | |
type="text" | |
name="q" | |
value="#encodeForHtmlAttribute( url.q )#" | |
hx-get="index.cfm" | |
hx-push-url="true" | |
hx-trigger="input delay:200ms" | |
hx-target=".search-results" | |
hx-select=".search-results" | |
hx-select-oob="pagination" | |
hx-indicator=".htmx-indicator" | |
/> | |
<button type="submit"> | |
Search | |
</button> | |
<span class="htmx-indicator"> | |
Loading.... | |
</span> | |
</form> |
Since the htmx-indicator
class is applied to an element that is a child of the <form>
, HTMX automatically treats it as the request indicator when the form is submitted. However, this isn't true for the hx-get
attribute on the search input. To use the same indicator for the active search (ie, the search triggered by the input), we have to use the hx-indicator
attribute to point to the Loading....
element.
To make this demo more intelligible, I've also added an artificial 5-second delay to any request that has a populated q
value. This gives the Loading....
indicator time to actively contribute to the user experience.
Another change that I made to the application is that I added an hx-sync
attribute on the body tag:
<body hx-boost="true" hx-sync="this:replace"> |
By default, the hx-boost
does not cancel any in-flight requests as the user navigates around the application. Which means, if the user triggers an "active search" request (which is artificially slowed-down in this iteration), and then immediately navigates to another page (such as a contact detail), the active search request will continue to run in the background. And, will be applied to the DOM and URL when it completes. This can lead to unexpected experiences.
By adding hx-sync="this:replace"
to the body tag, it's inherited by the entire app; and, as the user navigates to any page or triggers any AJAX requests, each request will immediately cause any in-flight requests to be aborted. This is more in alignment (in my opinion) with how the browser works natively.
Of course, you can always override this inherited hx-sync
behavior by adding an hx-sync
attribute lower down in the DOM tree if there's an area of interactions that you want to manage explicitly.
Aside: I opened a discussion about
htmx:abort
over on GitHub and Vincent/@telroshan had some great feedback about why thehx-sync
default behavior was in place (such as debouncing double-clicks); and also pointed out some of the trade-offs in synchronizing requests on the root. I think for the time being, I want to keep the synchronizing on the root (since this is closer to the normal behavior of the browser). But this definitely gave me a lot to think about. There are no "obviously right" solutions.
See relevant chapter in Hypermedia Systems. It's the section labeled, "Adding A Request Indicator".
v11 - Adds Button Disabling During Form Submission
This iteration of the ColdFusion + HTMX application adds the hx-disabled-elt
attribute to the add and edit forms to disable the submit buttons while the form is being processed. I've also added a sleep(500)
to these forms so that the disabled state of the button can be seen briefly during processing (otherwise the processing happens too quickly).
As I was experimenting with this attribute, I felt like there was a happy middle ground that was missing. Consider the hx-disabled-elt
attribute on a form tag. To disable the buttons in the form, I can do this:
<form hx-disabled-elt="button">
This works; but, it will disable all the buttons on the entire page during the form processing, not just the buttons within the form.
To limit the scope to the form, we could try to add the find
keyword:
<form hx-disabled-elt="find button">
This will scope the button
CSS selector to the form; but, it will only find the first matching button. If the form has multiple submit buttons, all the buttons after the first one would remain enabled during the form processing.
To limit the scope to the form but keep the selection broad, we could use traditional CSS techniques, like giving the form a class and then including that class in our global CSS selector:
<form class="my-form" hx-disabled-elt=".my-form button">
This would disable all buttons within our form thanks to the .my-form
scoping.
For the simplicity of the demo, I'm just using the global button
selector since I don't have any UIs with unrelated buttons strewn around the page. But, I thought it was worth discussing.
I've also opened an idea discussion on GitHub, suggesting that new CSS helper like findall
or findmany
might be a value-add for this kind of a scenario.
This wasn't a specific step in the Hypermedia Systems book, it was just something I wanted to consider after adding the hx-sync
to the root element in the last iteration (and the trade-off that double-submissions were no longer inherently handled by the implicit hx-sync
on each element).
v12 - Adds Lazy-Loaded Contact Count
This iteration of the ColdFusion + HTMX application adds the lazy loading of the total count of contacts. We are artificially slowing this request down (1.5 seconds) to make sure that it's an expensive request. Then, we're using hx-trigger="load"
to only make the request after the parent page has loaded.
The index.count.cfm
page does nothing but render the snippet of text that outputs the count:
<cfscript> | |
sleep( 1500 ); | |
contacts = contactModel.getByFilter(); | |
</cfscript> | |
<cfoutput> | |
(#numberFormat( contacts.len() )# in total) | |
</cfoutput> |
The trigger for this expensive request is being done from within the title tag:
<h1> | |
Contacts App | |
<span | |
hx-sync="this" | |
hx-get="index.count.cfm" | |
hx-trigger="load"> | |
<!--- Lazy load the contacts length. ---> | |
</span> | |
</h1> |
Recall that in an earlier iteration, I added hx-sync="this:replace"
on the <body>
tag. This forces all requests to synchronize on the content root (which mirrors the browser's native behavior). However, we don't necessarily want this lazy loading to interact with that pool of requests. As such, I'm including an hx-sync
attribute on the lazy loading content in order to isolate it within its own synchronization pool.
This is so easy to do!
But, once I had this in place, I noticed that when I paged through the results, each page was causing the count to re-fetch. This is because the pagination itself is doing a full page refresh. This navigation is still boosted; but, it's clearing out the whole page, causing the lazy loaded contact count to re-fetch.
This was a bit distracting. So, I updated the prev/next links to perform partial page swapping instead of whole page swapping. By adding hx-*
attributes to the pagination root, they will be inherited by both the prev and next links:
<div | |
id="pagination" | |
class="pagination" | |
hx-push-url="true" | |
hx-swap="show:window:top" | |
hx-target=".search-results" | |
hx-select=".search-results" | |
hx-select-oob="pagination"> | |
<cfif pagination.hasPrevious> | |
<a href="index.cfm?q=...."> | |
← <u>Prev</u> | |
</a> | |
</cfif> | |
<cfif pagination.hasNext> | |
<a href="index.cfm?q=...."> | |
<u>Next</u> → | |
</a> | |
</cfif> | |
</div> |
Here, the hx-target
and hx-select
attribute perform the partial page swap (instead of the whole page swap). And, on top of that, I'm using the hx-select-oob
to also perform an out-of-band swap of the contents of the pagination such that we don't show an erroneous prev or next link when it's no longer needed.
By default, HTMX seems to scroll to the hx-target
element upon swap. This was causing my prev/next links to jump down on the page. But, this felt unnatural. I wanted the prev/next links to still look and feel like full page loads. As such, I also added:
hx-swap="show:window:top"
This tells HTMX to scroll to the "top" of the "window" when the content is swapped-in / shown.
Aside: I think there's a way to vastly simplify some of the search interactions; but, I'll look at that in a future iteration.
See relevant chapter in Hypermedia Systems. It's the section labeled, "Lazy Loading".
v13 - Refactor Search Layout To Use "Focus Scroll" Behavior
This iteration of the ColdFusion + HTMX application refactors the search layout now that I've learned about the focus scroll feature of hx-swap
. Up till now, I've been trying to be surgical in my selection and swapping during the search results rendering because I was afraid to disrupt the search-input focus. But, it turns out that HTMX will maintain the focus of the currently-focused input during a swap as long as the input has an id
attribute (that holds consistently across the swap).
What this means is that I can actually wrap the entire search module - including the form, pagination links, and results table - inside a common container:
<div class="search-section"> | |
<form> | |
<input /> | |
<nav /> | |
</form> | |
<table /> | |
</div> |
Then, I can swap-out this common container for all the main interactions:
- Form submissions.
- Active search while typing (thanks to the "focus scroll").
- Pagination.
Also, I was able to move the active search off of the input
and simply add it as an additional hx-trigger
on the form itself. Here's my new form:
<form | |
method="get" | |
hx-trigger=" | |
submit, | |
input from:find input delay:200ms | |
" | |
hx-target=".search-section" | |
hx-select=".search-section" | |
hx-swap="show:window:top" | |
class="linear-form"> | |
<!--- other stuff ---> | |
</form> |
Notice that the hx-boost
'ed form now responds to both the natural submit
event and to the active search input from:find input
. Then, from the response, we're selecting the .search-section
, our common container, and swapping it. The hx-swap
directive tells HTMX to scroll to the top of the window (like a normal boosted operation). Note that the default hx-swap
behavior is to scroll to the top of the hx-target
.
My pagination is also using a similar tactic:
<div | |
hx-target=".search-section" | |
hx-select=".search-section" | |
hx-swap="show:window:top" | |
class="pagination"> | |
<a href="...">Prev</a> | |
<a href="...">Next</a> | |
</div> |
Again, we're using the hx-target
and hx-select
to brute-force swap the entire search area. Note that these attributes are being inherited by both the prev and next pagination links and therefore don't have to be duplicated within both links.
I think this is going to make some of the more dynamic interactions a lot easier if I don't have to worry about input focus (as long as the input has a consistent id
on it). This seems like a really handy behavior - I'm surprised it isn't more front-and-center in the public discourse. Hopefully I'm not missing something obvious.
Aside: Note that the
value
of the focused input still gets updated based on the server response. So, even though the focus is being maintained across thehx-swap
, it's possible that the input value will suddenly change out from under the user.
v14 - Adds A Delete Button To The Contacts List
This iteration of the ColdFusion + HTMX application adds a "Delete" action to the list of contacts. This allows each contact to be deleted without leaving the context of the contacts list.
In the book, this feature was implemented by making a request to the original delete page; and then, conditionally changing the response based on the existence of the HTMX HTTP headers. I've chosen to go a different route. Instead of re-purposing the existing delete page - and tightly coupling it to multiple contexts - I've created a new deletion end-point that is specifically designed for the index page.
Now, I have three index-related pages:
index.cfm
index.count.cfm
index.delete.cfm
- the new one for this iteration.
So, we already have an established pattern of creating small, sibling utility end-points for page-specific operations.
To implement this, I added a <button>
to each row that calls the index.delete.cfm
file using an hx-delete
request:
<cfloop array="#contacts#" item="contact"> | |
<tr> | |
<td /> | |
<td /> | |
<td /> | |
<td> | |
<button | |
hx-sync="this" | |
hx-delete="index.delete.cfm?id=#...#" | |
hx-target="closest tr" | |
hx-swap="outerHTML" | |
class="link-button"> | |
Delete | |
</button> | |
</td> | |
</tr> | |
</cfloop> |
Notice that the the hx-target
replaces the closest <tr>
. This new row content is generated by the new index.delete.cfm
page:
<cfscript> | |
// ... truncated validation ... | |
contactModel.deleteByFilter( id = contact.id ); | |
header | |
name = "HX-Trigger" | |
value = "contactDeleted" | |
; | |
</cfscript> | |
<cfoutput> | |
<tr class="deleted-row"> | |
<td colspan="4" style="text-align: center ;"> | |
#encodeForHtml( contact.name )# as been deleted. | |
</td> | |
</tr> | |
</cfoutput> |
There are two things to notice in this tailor-made page. First, it's returning the new <tr>
that's going to replace the original row in the table. So the inline delete doesn't actually remove the row; more so, it demarcates it as having been deleted. I think this reduces some of the potential jarring user experience.
Second, the deletion end-point is sending back the HTMX HTTP response header, HX-Trigger
. This will cause the contactDeleted
event to be fired on the client where we can use it to re-fetch the contacts count:
<span | |
hx-sync="this:replace" | |
hx-get="index.count.cfm" | |
hx-trigger=" | |
revealed, | |
contactDeleted from:body | |
"> | |
<!--- Lazy load the contacts length. ---> | |
(loading count...) | |
</span> |
Now, the (artificially) expensive "contact count" request will be initiated on both revealed
and contactDeleted
events.
This isn't a perfect solution; but, I do like the idea of creating small sub-pages for actions like this so that I can collocate behavior and reduce coupling across different sections of the application.
See relevant chapter in Hypermedia Systems. It's the section labeled, "Inline Delete".
v15 - Adds Bulk Delete
This iteration of the ColdFusion + HTMX application adds a bulk delete operation to the contacts list. Just as with the previous iteration, I've created a specialized end-point, sibling to the index page, that is designed for the bulk delete. However, unlike the one-off delete in the previous iteration, this version provides a checkbox-based selection; and, performs a full redirect of the page.
To start, I added a checkbox to each row that will report the associated contact ID as part of an array:
<td> | |
<input | |
form="bulkForm" | |
type="checkbox" | |
name="contactIDs[]" | |
value="#encodeForHtmlAttribute( contact.id )#" | |
/> | |
</td> |
Note that the input
is using the form
attribute to associate itself with a non-hierarchical form element. In the book, the authors wrapped the entire search results table in the form
tag; but for me, that approach was conflicting with the button
elements that I has previously added for the one-off deletes.
Instead, I put the form
at the bottom of the page, and allowed the form="bulkForm"
attributes to build the necessary associations. Even though there are no checkboxes located physically within the bounds of the form, the form submission will include any checked, associated input.
<form | |
id="bulkForm" | |
method="post" | |
action="index.deleteMany.cfm" | |
hx-confirm="Are you sure you want to delete the selected contacts?"> | |
<!--- | |
Pass through the query and pagination settings so that we can | |
re-route to this same page after deletion. | |
---> | |
<input type="hidden" name="q" value="..." /> | |
<input type="hidden" name="page" value="..." /> | |
<input type="hidden" name="pageSize" value="..." /> | |
<button type="submit"> | |
Delete Selected Contacts | |
</button> | |
</form> |
Again, I'm diverging from the book slightly. In the book, the authors are using an hx-delete
attribute on the submit button itself (and then targeting the body
tag). But, to me, it seemed easier to just navigate to the index.deleteMany.cfm
file, perform the delete, and then redirect back to the index page.
My approach incurs an additional request to the server; but, my approach also feels less "clever", which I always appreciate. Plus, by redirecting to a separate page, it gives me a clear place to render an error message should I ever need to (not that I am in this implementation).
Ultimately, I'm taking the user to the following deletion page, where I perform the multi-contact delete, and then redirect the user back to the search page. To help the user maintain context in the pagination, I'm passing through the search parameters; and then using them in the redirection as well. This way, if you multi-delete contacts on the 3rd search results page, I bring you back to the 3rd search results page:
<cfscript> | |
param name="form.contactIDs" type="array" default=[]; | |
param name="form.q" type="string" default=""; | |
param name="form.page" type="string" default=""; | |
param name="form.pageSize" type="string" default=""; | |
for ( id in form.contactIDs ) { | |
contactModel.deleteByFilter( id = id ); | |
} | |
goto( | |
"index.cfm" & | |
"?flash=contact.deletedMany" & | |
"&q=#encodeForUrl( form.q )#" & | |
"&page=#encodeForUrl( form.page )#" & | |
"&pageSize=#encodeForUrl( form.pageSize )#" | |
); | |
</cfscript> |
Just because I have HTMX, it doesn't mean that I have to HTMX'ify all the things. Part of what makes HTMX so powerful is that is builds on the native web platform. And sometimes that means doing full page redirects.
See relevant chapter in Hypermedia Systems. It's the section labeled, "Bulk Delete".
v16 - Adds A Download Feature
This iteration of the ColdFusion + HTMX application adds a "download contacts" feature. This exploration is a little janky because it attempts to explore a long-running asynchronous process (to generate the file); but, in reality, the process would be instant. As such, I have to jump through a number of hoops to simulate what the workflow would look like.
Ultimately, there's a div
at the bottom of the list page that runs the main contents of a different page:
<div | |
hx-target="this" | |
hx-select=".downloader" | |
hx-push-url="false" | |
class="downloader"> | |
<a href="download.cfm?action=start">Download contact list</a> | |
</div> |
When the user clicks the "Download" link, HTMX will "boost" the request; but, instead of targeting the body
element like HTMX would normally, we are inheriting the hx-target="this"
attribute to contain the requested content. Then, we're using the hx-select
to only grab the portion of the content with the matching class name, .downloader
.
The download.cfm
page then executes a polling operation, making repeated requests until a randomly selected duration has passed. As I mentioned above, there is no actual generation that's happening. As such, this is all theater.
That said, the download page uses the same kind of containment mechanism as above so that the same content will continue to be swapped locally even if the download link is opened in a new tab (ie, the download page uses the same mechanics).
The following was the lightest-weight way to perform this simulation - this is not an example of any best practices. I'm reproducing this file in full down below since I am too tired to explain it in any mediated way:
<cfscript> | |
param name="url.action" type="string" default="waiting"; | |
param name="url.startedAtMS" type="numeric" default=0; | |
param name="url.completedAtMS" type="numeric" default=0; | |
title = "Download Contact List"; | |
switch ( url.action ) { | |
case "start": | |
// When we start the download process, we're just going to select a fake | |
// duration and pass the start/end times through with each request. This is | |
// just to explore the workflow, not to be meaningful / correct. | |
fakeDurationMS = randRange( 3000, 7000 ); | |
startedAtMS = getTickCount(); | |
completedAtMS = ( startedAtMS + fakeDurationMS ); | |
percentComplete = 0; | |
break; | |
case "run": | |
// Calculate how close we are to the fake end-time. | |
total = ( url.completedAtMS - url.startedAtMS ); | |
delta = ( getTickCount() - url.startedAtMS ); | |
percentComplete = fix( delta / total * 100 ); | |
// If we've passed 100% progress, the redirect to the download call to action. | |
if ( percentComplete >= 100 ) { | |
goto( "download.cfm?action=done" ); | |
} | |
break; | |
} | |
</cfscript> | |
<cfsavecontent variable="body"> | |
<cfoutput> | |
<h1> | |
#encodeForHtml( title )# | |
</h1> | |
<div | |
hx-target="this" | |
hx-select=".downloader" | |
hx-push-url="false" | |
class="downloader"> | |
<cfswitch expression="#url.action#"> | |
<cfcase value="waiting"> | |
<a href="download.cfm?action=start">Start download</a>. | |
</cfcase> | |
<cfcase value="start,run"> | |
<div | |
hx-get="download.cfm?action=run&startedAtMS=#encodeForUrl( startedAtMS )#&completedAtMS=#encodeForUrl( completedAtMS )#" | |
hx-trigger="every 500ms"> | |
Generating... #encodeForHtml( percentComplete )#% | |
</div> | |
</cfcase> | |
<cfcase value="done"> | |
<!--- Since this points to a download, we have to DISABLE BOOST. ---> | |
<a href="download.json.cfm" hx-boost="false">Download generated JSON file</a>. | |
</cfcase> | |
</cfswitch> | |
</div> | |
</cfoutput> | |
</cfsavecontent> | |
<cfinclude template="_layout.cfm"> |
It's kind of cool to see it all come together - and with relatively little effort.
See relevant chapter in Hypermedia Systems. It's the section labeled, "A Dynamic Archive UI".
HTMX Is Pretty Dang Cool
By taking this journey, it really underscored how different it is to read about a topic vs. trying to implement it myself. So many of things that seemed so clear as I was reading the book suddenly became so confusing when I went to actually implement them for myself. This is the power of hands-on-learning. And is a lesson that I need to keep in the back of my mind.
HTMX seems pretty dang cool. I was surprised how relatively easy it was to get things up and running once I understood the HTMX mechanics. I still have so much learning to do; but, I feel like I'm moving in the right direction.
Want to use code from this post? Check out the license.
Reader Comments
So, this statement:
implements them via AJAX, swapping the entire contents of the page (less the <head>) with the ColdFusion server response
I would consider that pretty much what browsers do naturally! 🙃
I'll be honest, I didn't read the entire post here, but I'd be curious about your opinion on how you would consider this HTMX style of coding fitting in with other frameworks like Vue or React, if at all.
@Will,
So, yeah, that's basically what the browser is doing 😄 and, in fact, there's an interesting article that I came across a few weeks ago were the author was making an argument that in a lot of cases, you should just let the page perform a hard (ie, normal) reload during navigation events:
https://unplannedobsolescence.com/blog/hard-page-load/
And, that HTMX should just be used for things that aren't already going to be on the page during a normal page navigation. I'm probably not explaining that nearly as well as the author is, though.
As far as how this compares to more SPA-like frameworks, it's really a very different set of trade-offs. With the SPA stuff, you usually have to have a separate API that the SPA uses; and, everything has to run off the SPA code. With HTMX (and Hotwire Turbo, and Unpolly), it's more of a traditional server-side rendering approach (ie, what ColdFusion has historically done really well). Then, you're enhancing the experience (sometimes progressively, sometimes not) to have some of that rich interactivity without the complexity of the SPA.
Of course, it's not all free. You have to think differently about the architecture; and, you are giving up some of the very rich interactivity that you get with a SPA. But, you can always have parts of your application use SPA without having to commit entirely to the approach.
Though, to be clear, I'm still learning all this 😂 so, I'm not going to have all the best answers on hand.
Hi Ben,
Thanks for a great article about using HTMX. I am attempting to do the same in a SaaS I'm building. I like the idea of using the DOM to do a lot of simple work.
@Mike,
If you haven't, you should take a look at the Hypermedia Systems book. You can read it for free online (or purchase a physical copy):
https://hypermedia.systems/
It really walks through the mindset in a helpful way. I'm still very much just getting my feet wet here; there's a lot to think about.
I love that you're delving into htmx as I've always been curious about it but haven't (yet) taken the time to do a deep dive myself. I'm not (yet) convinced I need it to be honest. It may be a solution looking for a problem, but I'm open (and interested) to learn along with you.
When you delete the rows, would it be trivial to "undo" the deletion? I really love the pattern, which avoids the "confirm" dialog of any sort and just deletes, but offers a painless way to
ctrl+z
if you made a mistake. I think that's just great UX.I was just looking at the Datastar (https://data-star.dev/) examples which uses HTMX/Alpine.js and found them to be frustrating when editing forms. If I placed my cursor in the middle of a word, it would refresh an put the cursor at the end of the word. That's just terrible UX. Is this a common problem in HTMX? I noticed you might have experienced this when you were editing the phone number in your contact form, but I couldn't be sure.
Hypermedia Systems is an excellent book, Ben.
The problem I am trying to solve is reducing brittle UI with dependencies on others and constant changes to libraries for large enterprise systems.
The plan.
Use CFML for the rest. (logged in users only)
Hey Ben,
Boy, that was a deep dive on HTMX, thanks for sharing.
For me, I look at HTMX as a principle, rather than a Javascript library.
It's knowing you can do things like dom changes, active search, and submitting form data simply using bits of vanilla Javascript.
All without shipping tons of JS, using npm, or having to bundle anything. Magical.
You just have to learn Javascript.
But to switch gears, I'd like to comment on what Mike said above.
For years now (since Netlify and Vercel), we have been pushed to believe that non-dynamic pages should always be pre-rendered.
For cost considerations it makes sense, since hosting static sites is often free.
But for anything else, that notion is mostly nonsense.
Mike is absolutely right to remove as many third-party dependencies as possible.
And as admirable as it is to use CF to build static pages, you really don't need to.
Consider this...
Render all your pages using CF, use vanilla JS to control user events.
Cheers
@Chris,
These are all great questions! And, to be honest, I don't have great answers for any of them :P Is this all necessary? It's so hard to say. Since this is already skewing more towards a traditional request-response, multi-page application architecture, and leans heavily on the ideas of "progressive enhancement", it's easy to wonder if it's worth the frustration of then having to deal with more complexity.
In one of the earlier comments, I linked to an article in which the author talks about hard-refreshes, and about how we should really just keep doing normal page-to-page navigations and then use HTMX just for the in-page fanciness. And, part of me really loves that idea because it removes a whole bunch of issues (like how to do you effectively handle errors and redirects).
The more I learn about this stuff, the more I just have questions! 😂
re: Undo - I think that would really come down to how you architect the app. You'd have to be able to actually undo the action at the data level as well (like maybe you're using "soft deletes" in the CFWheels framework). In a case like that, the
<tr>
that is returned in the deletion request, that gets swapped into the DOM, could include a button that clears thedeletedAt
field, and then returns another<tr>
with the "active" row. So, I think you can do that kind of stuff, but it's not just a UI concern at that point.The datastar stuff is really interesting! I was just yesterday listening to an interview on the HX-Pod (podcast) with the Datastar creator. I even went on to linkedin to ask a question about how Datastar would work with load-balanced servers. I feel like there's a big puzzle piece missing in my head.
re: inputs and focus, one nice thing that HTMX does is that it (seems) to maintain the focus and the carrot position of the currently focused input at the time of the swap. The phone-number issue you saw in the video was me just editing some typos out of the video itself :D
@Mike,
I 100% feel your pain about reducing brittle UI. You're talking to someone who worked on a massive AngularJS application, only to see it end-of-life'd (AngularJS) leaving my application living on a non-maintained framework.
One of the things that I like about HTMX and other frameworks in this category is that they are just trying to do so much less. It's not a "batteries included" kind of approach; more like a few primitives that you can build on. So, I think there's much less of an opportunity for things to break. Though, that's just a feeling - not any kind of statement of fact. I'm still learning all this stuff myself.
To help reduce brittleness in general, my best advice is always "prefer copy/paste over coupling". The biggest source of issues for me and my teams is when someone changes something here and it breaks something over there in an unexpected way.
To this end, in my HTMX exploration here, for things that were index-page (list of contacts) specific, I created separate little utility files to aide in the interactions (ex,
index.delete.cfm
) rather than trying repurpose some existing end-point to also work with the index page. The way I see it, the "controller layer" should be inexpensive to build as long as you're drawing good boundaries around the shared business logic. As such, creating specialized utility / helper pages shouldn't be a big cost; and, should serve to reduce coupling and complexity by a good margin.As far as static site generation, I've never tried that. I've always been curious to try though. But, I wonder if you're starting to make things too complex at that point. It's one thing to reduce a brittle UI, it's another thing to make the developer experience harder.
@Bill,
I tend to agree. I would prefer in-memory caching (in ColdFusion) before I'd worry about static content generation if performance is a major concern. One of the magical things about ColdFusion is how so much of it "just works": you create a CFM file, you deploy it, and viola - you have a dynamic page. No compilation step, no creating Views and code-behinds. No ceremony.
The older I get the more I want things to be simple simple simple!
Now, when I develop, I am constantly paying attention to the pain points that I'm feeling. Every time I have to jump back and forth between files, or between folders, or scroll up and down, I quietly ask myself, is there a way I could reduce this friction? How could I make this simpler? Is there a better way to collocate behaviors and logic?
It's a constant quest for the right trade-offs, finding the ones that make life easier, but without sacrificing too much of something else.
Ben,
I love the experimentation to see what works best with HTMX.
I used HTMX in a "Ribbon" UI Component. It seemed to work oK, but I'm still learning.
I have been using a home-built CF-driven static page generator that uses a database for the source since 2004. The biggest science reference website it rendered was 90,000 + pages. The current version renders 300 Wikipedia-sized HTML pages per/second. One result of using this method is the size of each page. Usually 10-30kb instead of 1 Mb +. A massive gain in simplicity. ❤️
There is no right or wrong in this, it depends on the problem to be solved.
Post A Comment — ❤️ I'd Love To Hear From You! ❤️