HTMX (and a little Alpine.js) for a SPA-like experience in ASP.NET Core (English)

HTMX (and a little Alpine.js) for a SPA-like experience in ASP.NET Core

Comments

NOTE: Apart from English (and even then it's questionable, I'm Scottish). These are machine translated in languages I don't read. If they're terrible please contact me.
You can see how this translation was done in this article.

Sunday, 15 September 2024

//

Less than a minute

Introduction

In this site I use HTMX extensively, this is a super easy way to make your site feel more responsive and smoother without having to write a lot of JavaScript.

NOTE: I'm not entirely happy with this yet, HTMX has some elements which make this tricky

HTMX and ASP.NET Core

Using HTMX with ASP.NET Core is a bit of a dream, I've written about this previously in this post. It's really pretty easy to 'HTMXify' your site, and it's a great way to make your site feel more responsive.

As part of my latest go around using HTMX I decided to make as many links on this site use HTMX as possible. So for example if you click the 'Home' button above it now uses HTMX to load the page content.

I achieve this by simply adding some properties to the link tag, like this:

    <a asp-action="IndexPartial" asp-controller="Home" hx-swap="show:window:top"  hx-target="#contentcontainer" hx-boost="true" class="mr-auto sm:mr-6">
     <div class="svg-container">
        <img src="/img/logo.svg" asp-append-version="true"  width="180px" height="30px"  alt="logo"  :class="{ 'img-filter-dark': isDarkMode }"/>
     </div>
    </a>

Here you can see I add the hx-swap attribute, this tells HTMX to swap the content of the target element with the content returned from the server.

NOTE: DO NOT use hx-pushurl as this will cause the browser to reload the page on backbutton twice.

The hx-target attribute tells HTMX where to put the content returned from the server. In this case it's the #contentcontainer element (where all the page content is loaded).

I also add hx-swap="show:window:top which makes the window scroll to the top of the page when the content is swapped.

The C# part of this is pretty simple, it just duplicates my normal Index method but instead loads the partial view. The code is shown below. Again this uses HTMX.NET to detect the hx-request header and return the partial view. There's also a cut-out to return the normal view if the request is not an HTMX request.

    [Route("/IndexPartial")]
    [OutputCache(Duration = 3600, VaryByHeaderNames = new[] { "hx-request" })]
    [ResponseCache(Duration = 300, VaryByHeader = "hx-request",
        Location = ResponseCacheLocation.Any)]
    [HttpGet]
    public async Task<IActionResult> IndexPartial()
    {
        ViewBag.Title = "mostlylucid";
        if(!Request.IsHtmx()) return RedirectToAction("Index");
        var authenticateResult = await GetUserInfo();
        var posts = await BlogService.GetPagedPosts(1, 5);
        posts.LinkUrl = Url.Action("Index", "Home");
     
        var indexPageViewModel = new IndexPageViewModel
        {
            Posts = posts, Authenticated = authenticateResult.LoggedIn, Name = authenticateResult.Name,
            AvatarUrl = authenticateResult.AvatarUrl
        };
        return PartialView("_HomePartial", indexPageViewModel);
    }

The outcome of this is that this link (and others using the same technique) load as 'AJAX' requests and the content is swapped out without a full page reload. This avoids the annoying 'light flash' you get when a page reloads due to how my theme switching works.

So the method below CAN be made to work however it's super tricky and a bit awkward. HTMX really likes to work using the attribute based approach. Using htmx.ajax is kind of the 'if all else fails' method and means you need to mess with borwser history. Unfortunately browser history using pushState replaceState and popstate is really a bit of a pain in the neck (but can be super powerful for edge cases).

Instead what I'm now doing is just adding hx-boost to the tag which contains blog content.

   <div class="prose prose max-w-none border-b py-2 text-black dark:prose-dark sm:py-2" hx-boost="true"  hx-target="#contentcontainer">
        @Html.Raw(Model.HtmlContent)
    </div>

This seems to work well as hx-boost can be placed on parent methods and apply to all child links. It's also more standard HTMX without messing with history etc.

In the rare occasion that you DON'T want to use HTMX for particular links you can just add hx-boost="false" to the link.

However a problem with this approach was my blog links like this one: HTMX for a SPA-like experience which are not 'normal' links. These links are generated by the markdown parser; while I COULD write a MarkDig extension to add the HTMX attributes to the links, I decided to use a different approach (because I already have a ton of content I don't want to re-parse).

Instead I added a JavaScript function which looks for all these types of link, then uses htmx.ajax to do the swap. This is essentially what HTMX does anyway just 'manual'.

As you can see this is just a simple function that looks for all the links in the div.prose element that start with a / and then adds an event listener to them. When the link is clicked the event is prevented, the URL is extracted and then the htmx.ajax function is called with the URL and the target element to update.

(function(window) {
   
    window.blogswitcher =() =>  {
        const blogLinks = document.querySelectorAll('div.prose  a[href^="/"]');

        // Iterate through all the selected blog links
        blogLinks.forEach(link => {
            link.addEventListener('click', function(event) {
               event.preventDefault();
                let link = event.currentTarget;
                let url = link.href;
                htmx.ajax('get', url, {
                    target: '#contentcontainer',  // The container to update
                    swap: 'innerHTML',            // Replace the content inside the target


                }).then(function() {
                    history.pushState(null, '', url);
                  
                        window.scrollTo({
                            top: 0,
                            behavior: 'smooth' // For a smooth scrolling effect
                        });
                    
                });
            });
        });
        
    };
})(window);

Additionally once the swap has been made I then push the new URL to the browser history and scroll to the top of the page. I use this approach in a few places (like search) to give a nice buttery smooth experience.

Back Button (This is tricky to get working)

The back button in this type of app can be problematic; MANY 'proper' SPAs either disable the back button or put up with this incorrect behaviour. However I wanted to make sure the back button worked as expected. Note: To get the back button to work as expected we also need to listen for the popState event. This event is fired when the user navigates back or forward in the browser history. When this event is fired we can reload the content for the current URL.

window.addEventListener("popstate",   (event) => {
 // When the user navigates back, reload the content for the current URL
 event.preventDefault();
 let url = window.location.href;
 // Perform the HTMX AJAX request to load the content for the current state
 htmx.ajax('get', url, {
  target: '#contentcontainer',
  swap: 'innerHTML'
 }).then(function () {
  // Scroll to the top of the page
  window.scrollTo({
   top: 0,
   behavior: 'smooth'
  });
 });
});

In this code we prevent the default browser popstate behaviour with the event.preventDefault() call. We then extract the current URL from the window.location.href property and perform an HTMX AJAX request to load the content for the current state. Once the content has been loaded we scroll to the top of the page.

As mentioned previously you cannot ALSO use hx-pushurl as this will cause the browser to reload the page on backbutton twice.

HTMX more generally (and Alpine.js)

As you may have guessed I'm somewhat a fan of HTMX, combined with ASP.NET Core and a smattering of Alpine.js it empowers devs to create a really nice user experience with minimal effort. The major thing I prefer over the likes of the more 'fully formed' client side frameworks like React / Angular etc...is that it still gives me a full server-side rendering pipeline with ASP.NET Core but the 'feel' of a SPA. Alpine.js enables simple client side interactivity, a very simple feature I added recently was making my categories 'hidden' but expand on a click.

Using Alpine.js for this means there's no additional JavaScript required.

        <div class="mb-8 mt-6 border-neutral-400 dark:border-neutral-600 border rounded-lg" x-data="{ showCategories: false }">
            <h4 
                class="px-5 py-1 bg-neutral-500  bg-opacity-10 rounded-lg  font-body text-primary dark:text-white w-full flex justify-between items-center cursor-pointer"
                x-on:click="showCategories = !showCategories"
            >
                Categories
                <span>
                    <i
                        class="bx text-2xl"
                        :class="showCategories ? 'bx-chevron-up' : 'bx-chevron-down'"
                    ></i>
                </span>
            </h4>
            <div 
                class="flex flex-wrap gap-2 pt-2 pl-5 pr-5 pb-2"
                x-show="showCategories" 
                x-cloak
                x-transition:enter="max-h-0 opacity-0"
                x-transition:enter-end="max-h-screen opacity-100"
                x-transition:leave="max-h-screen opacity-100"
                x-transition:leave-end="max-h-0 opacity-0"
            >
                @foreach (var category in ViewBag.Categories)
                {
                    <partial name="_Category" model="category"/>
                }
           
            </div>
        </div>

Here you can see I use the x-data attribute to create a new Alpine.js data object, in this case showCategories. This is a boolean that is toggled when the h4 element is clicked. The x-on:click attribute is used to bind the click event to the toggle function.

Within the list of Categories I then use the x-show attribute to show or hide the list of categories based on the value of showCategories. I also use the x-transition attribute to add a nice slide down effect when the categories are shown or hidden. The x-cloak attribute is used to prevent the 'flicker' of the categories being shown before the JavaScript has run. I have a little CSS class defined for this:

[x-cloak] { display: none !important; }

In Conclusion

So that's it, a short article on how to use HTMX and a spot of Alpine.js to make your site feel more responsive and 'SPA-like'. I hope you found this useful, if you have any questions or comments please feel free to leave them below.

Scott Galloway 28 days ago
Approved

James, yes in fact see the Back Button the Simpler Way section. This is how I wound up actually doing it. I'll likely update the post to make that clearer later.

James White 28 days ago
Approved

I wonder if there's a way to use htmx's built in history management (i think it already implements the popstate listener) in these examples? For some reason I have a deep aversion to writing back-button handlers.

logo

©2024 Scott Galloway