May 7, 2023

Using htmx.org

I’ve been reading a lot about htmx and decided I would need to check it out. I don’t have a particular project I am working on currently, so instead I just ported the famous Spring PetClinic project to use htmx besides its standard stack which is based on Spring Boot, Thymeleaf and Spring Data JPA. The last one we don’t care for this project.

What is htmx?

As explained on htmx website:

introduction

htmx is a library that allows you to access modern browser features directly from HTML, rather than using javascript.

htmx gives you access to AJAX, CSS Transitions, WebSockets and Server Sent Events directly in HTML, using attributes, so you can build modern user interfaces with the simplicity and power of hypertext.

motivation

  • Why should only <a> and <form> be able to make HTTP requests?
  • Why should only click & submit events trigger them?
  • Why should only GET & POST methods be available?
  • Why should you only be able to replace the entire screen?

By removing these arbitrary constraints, htmx completes HTML as a hypertext

This sound cool and all. The website have an essays part which is fascinating to read, going from when to use hypermedia, to SPA alternatives, decoupling etc.

I do personally love the haiku:

javascript fatigue:
longing for a hypertext
already in hand

Why htmx?

Every app doesn’t have to be a Single Page Application consuming a generic REST API (which is more than very often tailored to the frontend). So in such a case why bother decoupling, having the business logic in two different apps, dealing with different technologies (let’s say a Go backend and a TypeScript frontend written with React).

Of course having a separated frontend has its own pros, but let’s take a look at some of the cons:

  • Increased complexity: by separating the frontend from the backend, we add an extra layer of complexity to the application, requiring additional effort and resources to maintain both the frontend and backend separately. Think about maintaining states on both ends.
  • Performance: Single Page Applications usually take longer to load initially since they require the downloading and parsing of JavaScript files before the application can render. Not taking into account all the subsequent HTTP requests needed to initiate the whole page.
  • SEO: We all know about some of the difficulties of SPAs on SEO.
  • Security and validation: by separating both the frontend and backend, we can introduce security vulnerabilities if proper authentication and authorization mechanisms are not in place. Same for validation which logic has to be duplicated, mainly for user experience.
  • JavaScript disabled: SPAs won’t simply work correctly with a disabled JS browser, where on the contrary MPAs may degrade silently to work fine while JS is disabled.

By using some tooling like HTMX to simulate SPA behavior, we can mitigate some of these downsides while still providing a Single Page Application like experience.

So far so good, and I didn’t even talk about the current JS ecosystem and tooling which I personally find infuriating to use. I have been managing React apps for years now, and it’s starting to become more and more complicated I find.

Hypermedia

I will just link some of the essays I find interesting related to this, so I won’t spend much time talking about its merits, so have a look at these instead, it does a better job than me at introducing it.

Spring Clinic

As I said I am currently not working on a specific project during my own time, and at work we’re using React, so it’s not like I will have an opportunity to try htmx there soon, but maybe, who knows?

Therefore, I took a look at the various forks of Spring PetClinic, and none was to be found using htmx. It took me an afternoon to migrate it, needed some time to refresh my thymeleaf knowledge because I didn’t use it for a long time.

This is pretty much what you need to know to migrate such a Spring webmvc application:

Add the necessary dependencies

<dependencies>
  <!-- webjars -->
  <dependency>
    <groupId>org.webjars.npm</groupId>
    <artifactId>htmx.org</artifactId>
    <version>${htmx.org.version}</version>
  </dependency>
  <dependency>
    <groupId>org.webjars.npm</groupId>
    <artifactId>hyperscript.org</artifactId>
    <version>${hyperscript.org.version}</version>
  </dependency>
  <!-- htmx thymeleaf -->
  <dependency>
    <groupId>io.github.wimdeblauwe</groupId>
    <artifactId>htmx-spring-boot-thymeleaf</artifactId>
    <version>${htmx-spring-boot-thymeleaf.version}</version>
  </dependency>
</dependencies>

Add the JS in your layout

<script th:src="@{/webjars/htmx.org/1.9.2/dist/htmx.min.js}" />
<script th:src="@{/webjars/hyperscript.org/0.9.8/dist/_hyperscript.min.js}" />

Let’s migrate the owners list view

In order to have the website working when JS is disabled, and when it’s enabled (thus using htmx), we’re going to extract both the find form and the listing inside their own fragments.

templates/owners/findOwners.html

<html xmlns:th="https://www.thymeleaf.org"
      th:replace="~{fragments/layout :: layout (~{::body},'owners')}">
  <body>

    <div th:replace="~{fragments/owners :: find-form}"/>

  </body>
</html>

Same goes for templates/owners/ownersList.html

<!DOCTYPE html>
<html xmlns:th="https://www.thymeleaf.org" th:replace="~{fragments/layout :: layout (~{::body},'owners')}">
  <body>

    <div th:replace="~{fragments/owners :: list}" />

  </body>
</html>

Now create a templates/fragments/owners.html file like this:

<div th:fragment="find-form" th:remove="tag">
  <h2>Find Owners</h2>

  <form th:object="${owner}" th:action="@{/owners}" method="get"
        hx:get="@{/owners}" hx-swap="innerHTML" hx-target="#block-content"
        class="form-horizontal" id="search-owner-form">
    <div class="form-group">
      <div class="control-group" id="lastNameGroup">
        <label class="col-sm-2 control-label">Last name </label>
        <div class="col-sm-10">
          <input class="form-control" th:field="*{lastName}" size="30"
                 maxlength="80"/> <span class="help-inline"><div
                th:if="${#fields.hasAnyErrors()}">
              <p th:each="err : ${#fields.allErrors()}" th:text="${err}">Error</p>
            </div></span>
        </div>
      </div>
    </div>
    <div class="form-group">
      <div class="col-sm-offset-2 col-sm-10">
        <button type="submit" class="btn btn-primary">Find
          Owner
        </button>
      </div>
    </div>

    <a class="btn btn-primary" hx:get="@{/owners/new}" hx-swap="innerHTML" hx-target="#block-content"
       hx:push-url="@{/owners/new}" th:href="@{/owners/new}">Add Owner</a>

  </form>
</div>

<!-- see fragment "list" below -->

Just above you can see the two fragments, one for the form and the other for the listing of users that we found in the database.

The first fragment named find-form has a form tag which have the following attributes:

  • hx:get="@{/owners}": which means that htmx will issue a GET requests at /owners when submitting the form, using the various form fields that we need (like lastName)
  • hx-target="#block-content": means that the response received at that URL by htmx will be injected in the #block-content tag in the DOM
  • hx-swap="innerHTML": instructs htmx to swap the innerHTML content of that #block-content

Now let’s see how we deal with that in the backend:

public class OwnerController {
  // ...

  @GetMapping("/owners/find")
  public String initFindForm() {
    return "owners/findOwners";
  }

  @HxRequest
  @GetMapping("/owners/find")
  public String htmxInitFindForm() {
    return "fragments/owners :: find-form";
  }
  
  // ...
}

We have two handler bound to /owners/find, the first one is used when no htmx is used, and will return the view owners/findOwners which contains the entire website layout.

However, using the @HxRequest we’re able to target requests that are coming through htmx (because htmx will send a HX-Request: true HTTP header with every API call), and in such a case we’ll send back only the fragment named find-form, this way we won’t send the whole website layout on the wire, only the necessary page content to be updated.

Example: when clicking on Find Owners:

Rightly, for the listing response we’ll do exactly the same:

public class OwnerController {
  // ...

  @GetMapping("/owners")
  public String ownersList(@RequestParam(defaultValue = "1") int page, Owner owner, BindingResult result, Model model,
                           HttpServletResponse response) {
    return processFindForm(page, owner, result, model, response, "owners/findOwners", "owners/ownersList");
  }

  @HxRequest
  @GetMapping("/owners")
  public String htmxOwnersList(@RequestParam(defaultValue = "1") int page, Owner owner, BindingResult result,
                               Model model, HttpServletResponse response) {
    return processFindForm(page, owner, result, model, response, "fragments/owners :: find-form",
            "fragments/owners :: list");
  }

  public String processFindForm(@RequestParam(defaultValue = "1") int page, Owner owner, BindingResult result,
                                Model model, HttpServletResponse response, String emptyView, String listView) {
    // ...

    // find owners by last name
    Page<Owner> ownersResults = findPaginatedForOwnersLastName(page, owner.getLastName());
    if (ownersResults.isEmpty()) {
      result.rejectValue("lastName", "notFound", "not found");
      return emptyView;
    }

    if (ownersResults.getTotalElements() == 1) {
      owner = ownersResults.iterator().next();
      return "redirect:/owners/" + owner.getId();
    }

    // ...
  }
  
  // ...
}

We’ll have one processFindForm method that handle what the Spring PetClinic code was doing by default, but it takes two additional String parameters, which are supposed to represent the thymeleaf view to return in case there are no users to be found, and in case of success.

Here it will be owners/findOwners + owners/ownersList in case of no htmx, and find-form + list otherwise.

Regarding the thymeleaf fragment here is the list one:

<div th:fragment="list" th:remove="tag">
  <h2>Owners</h2>

  <table id="owners" class="table table-striped">
    <thead>
    <tr>
      <th style="width: 150px;">Name</th>
      <th style="width: 200px;">Address</th>
      <th>City</th>
      <th style="width: 120px">Telephone</th>
      <th>Pets</th>
    </tr>
    </thead>
    <tbody>
    <tr th:each="owner : ${listOwners}">
      <td>
        <a th:href="@{/owners/__${owner.id}__}" th:text="${owner.firstName + ' ' + owner.lastName}"
           th:attr="hx-get=@{/owners/__${owner.id}__}" hx-target="#block-content"/></a>
      </td>
      <td th:text="${owner.address}"/>
      <td th:text="${owner.city}"/>
      <td th:text="${owner.telephone}"/>
      <td><span th:text="${#strings.listJoin(owner.pets, ', ')}"/></td>
    </tr>
    </tbody>
  </table>
  <div th:if="${totalPages > 1}">
    <div th:replace="~{fragments/pagination::component('/owners', ${totalPages}, ${currentPage})}"/>
  </div>
</div>

Here you can see that for each owner in ${listOwners} we will use htmx to drive the click on the name by using:

  • th:attr="hx-get=@{/owners/__${owner.id}__}", meaning that it will call /owners/{owner.id} with a GET request
  • hx-target="#block-content", and still we want to replace the #block-content content.

Additional attributes I had to use during the port to htmx would be hx:push-url (or adding HX-Push-Url directly from the backend controller). You will find them all described on the htmx.org/reference webpage.

Testing

For testing purposes we just have to give the mockMvc a builder that includes or not a HX-Request: true header:

public static MockHttpServletRequestBuilder toggleHtmx(MockHttpServletRequestBuilder builder, boolean toggle) {
    if (toggle) {
        builder.header("HX-Request", "true");
    }

    return builder;
}

And using some parameterized tests you can do like that:

@CsvSource({ 
    "false,owners/ownersList", 
    "true,fragments/owners :: list"
})
@ParameterizedTest
void testProcessFindFormSuccess(boolean hxRequest, String expectedViewName) throws Exception {
    Page<Owner> tasks = new PageImpl<Owner>(Lists.newArrayList(george(), new Owner()));
    Mockito.when(this.owners.findByLastName(anyString(), any(Pageable.class))).thenReturn(tasks);
    mockMvc.perform(toggleHtmx(get("/owners?page=1"), hxRequest))
        .andExpect(status().isOk())
        .andExpect(view().name(expectedViewName));
}

Here we verify that the controller will return the full view when no htmx, and the fragment otherwise.

Demo

As you can see, there are no full page reload at all during the demo, every click is handled by htmx and the backend is serving exactly the right piece of server side rendered HTML code that needs to be displayed.

Every page can be bookmarked + shared and the browser’s back button works as it should.

Of course, we could add some indicator using hx-indicator to add some loading animations, maybe next time.

Easy

As you can see this is pretty straightforward, and what’s very cool about that is that you can migrate parts of the app as you go, there are no big bang change, you can just migrate one endpoint, one view there and there and do it as you like.

Further reading

Some articles and videos that I found interesting:

Repository

You can find this fork at: agrison/spring-petclinic-htmx.

I’m not sure yet how to be listed in this page The Spring PetClinic Community, so that it becomes collaborative, also I have some ideas for improvements.

What’s next?

Not sure if htmx will be a thing, but we can clearly see some trends of simplification, for example regarding monoliths and microservices, there is really a Javascript fatigue going on, and supporting some open source project to write Javascript so that we don’t have to could be beneficial.

As I said, not every app should be a SPA, there’s nothing wrong with a traditional MVC app, and as seen above it is straightforward to make it more dynamic using just some htmx attributes.

I didn’t deep very dive for the moment as the pet clinic can be adapted without a big knowledge of htmx, I didn’t have a necessity for triggers, swap-oob and stuff.

Until next time!

Alexandre Grison - //grison.me - @algrison