Modern Frontends With HTMX
Modern Frontends With HTMX
with htmx
Use htmx with Spring Boot and Thymeleaf to build dynamic
and interactive web applications
Wim Deblauwe
No part of this publication may be reproduced, stored in a retrieval system or transmitted in any form
or by any means, electronic, mechanical, photocopying, recording, scanning or otherwise except as
permitted under Sections 107 or 108 of the 1976 United States Copyright Act, without the prior
written permission of the publisher.
While every precaution has been taken in the preparation of this book, the publisher and author
assume no responsibility for errors and omissions, or for any damage resulting from the use of the
information contained herein. The book solely reflects the author’s views.
Foreword
In my early days of professional programming, web application development generally followed a
simple pattern: you pick a server-side technology, primarily according to your preference of
programming language, development environment and complexity. Within each of those available
tech stacks, a rough dozen of web frameworks existed that offered some flavor of the Model View
Controller (MVC) pattern Those in turn supported various templating languages to enrich HTML
documents with dynamic elements to be composed with the data residing on the server to produce a
response. A consequence of that approach was that every user interaction with the browser required
a full server round trip, rendering the full page again.
Enter the Web 2.0 era. Web applications become much more dynamic by using AJAX requests.
JavaScript enters the scene and libraries like jQuery become ubiquitous tools to sprinkle interactive
elements into websites. Over the next decade, what started as enhancing addition, turned into fully-
fledged frontend frameworks that, to a large degree, replicate many features that are present in the
server-side ones: routing, state management, view rendering. That increase in complexity is multiplied
by the need for additional tooling to build and deploy those, now separate, frontend applications.
Furthermore, the backend now usually specializes in producing APIs the frontend can use, primarily in
the form of providing access to data conveniently. Of course, that kind of sophistication on either side
requires specialized skills and leads to developers slowly diverging into different camps. Nowadays, it
is difficult to find a company developing web applications that does not feature dedicated backend
and frontend teams, each specializing in the development of their specific part of the application. Up
to a point, at which Conway’s Law kicks in: even simple, form-based applications are often developed
with a frontend backend split, simply because that is the way the organization is structured.
What is interesting about that kind of development (pun intended), is that it happens while the agile
movement becomes the primary approach to software development in general. Cross-functional, self-
determined teams are supposed to deliver value to customers in the form of working software as
quickly as possible. Splitting up our development teams alongside technical boundaries does not
really seem to help with that. Especially if that means that neither of the two can actually ship a
feature on their own. During all that time, Spring Framework has been a ubiquitous player in the Java
world to help developers build — not only — web applications. Unsurprisingly, it loosely followed the
changes in approaches that I just described: primarily featuring Struts in its early days, different
flavors of an own MVC framework supporting view technologies like JSP, Freemarker and ultimately
Thymeleaf. The shift towards backend applications primarily providing data to frontends shifted the
focus to JSON-based HTTP APIs, with bits of hypermedia elements in those for the more ambitious
crowd. Building server-side rendered applications did not seem that appealing.
That said, the trend of ever-growing complexity in web application development has produced a
countermovement, culminating in projects like htmx, which this book is all about. With Taming
Thymeleaf Wim already wrote the de-facto standard book on server-side rendered web application
development with Spring. Unsurprisingly, htmx caught his interest, and he has been at the forefront
of the community efforts to make it work easily within the Spring ecosystem. When I read the
manuscript for this book for the first time, I could not have been more excited. It is a great, practical
guide through htmx, Thymeleaf and Spring Boot and shows immediately applicable examples that
each highlight a particular use case of the stack. I am pretty sure that this combination of technologies
is going to play a very significant role in the next era of web application development with Java. And
whom to better learn from about this than an expert on all three of those?
Oliver Drotbohm
2 | Foreword
Modern frontends with htmx
Acknowledgements
I would first and foremost like to thank Carson Gross for creating htmx. It is only a little JavaScript
library, on one hand; but allows so many developers to write rich interactive web applications in a
simple way on the other hand.
I also want to send a big thank-you to the people that created Asciidoctor (Especially Dan Allen), and
to Alexander Schwartz for his amazing work on the IntelliJ Asciidoc plugin. It made writing this book
extremely enjoyable.
Further, I also want to thank my sister-in-law Jasmine Verhaeghe for the work on the book’s cover. I
am really happy with how it looks.
Finally, I want to thank Thomas Maxwell, Oliver Drotbohm, Frederik Hahne and Thomas Schuehly for
reviewing the book. Their feedback has been invaluable for making this book the best it can be.
Acknowledgements | 3
Modern frontends with htmx
Introduction
Imagine creating dynamic, interactive web applications with minimal JavaScript. That’s the
revolutionary promise of htmx, a technology that redefines frontend development. This book is your
gateway to mastering htmx alongside Java, Spring Boot, and Thymeleaf, transforming the way you
build web interfaces.
I can’t really remember when I learned about htmx for the first time, but I do know for sure it was via
Twitter (Now called X). The account has some hilarious memes. But memes alone are not enough to
grab my attention. What did grab my attention is the fact that htmx claims to drastically simplify
frontend development.
I love Spring Boot and Thymeleaf. However, those of you that have read my previous book Taming
Thymeleaf, might have seen that it is not so easy to make something interactive. There was lots of
JavaScript involved to allow editing the players of a team using UI patterns that users currently expect
from a frontend application. Today, with htmx, I can discard most of that JavaScript for simpler
implementations based on the idea of hypermedia.
The fantastic thing about htmx that it makes modern UI patterns like lazy loading, endless scroll, inline
editing, feedback-as-you-type, … real easy to implement. What’s equally great is the minimal learning
curve involved. Htmx just continues where HTML got stuck in 1995. It allows issuing a server request
from any element in your HTML. After all, why should only <a> and <form> be allowed to issue
request?
This book focuses on htmx combined with Java, Spring Boot, and Thymeleaf. However, it’s adaptable
to various backend technologies. Htmx works equally well with Python/Django, PHP/Laravel,
.NET/Blazor, … When using htmx, you need a backend that can return fragments like we have with
Thymeleaf. This allows for precise HTML updates to update the page dynamically without page
reloads.
Throughout the book, I’ve included diverse examples. They’re designed not just to inform, but also to
inspire. Let them be the spark for your own creative and technological journey.
4 | Introduction
Modern frontends with htmx
Source code
The source code of the book can be found on GitHub at https://github.com/wimdeblauwe/modern-
frontends-with-htmx-sources
There is a directory per chapter with how the code is supposed to look by the end of each chapter.
Source code | 5
Modern frontends with htmx
1.1. htmx
htmx is the core technology that this book will use to build our modern frontends.
It is a JavaScript library that enhances HTML through the use of attributes on its elements. From the
website:
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
The core idea of htmx is that you swap HTML snippets on the page in the browser with new HTML
snippets coming from the server. Those can come as a response to a request, or can be pushed from
the server in case of Server-Sent Events or websockets.
Htmx is backend framework-agnostic. You can use it with PHP, Python, .NET, Java, … Really, anything
that can return HTML from the server will work with it.
One of the key things you need to understand is that Spring is based on the concept of "beans" or
"components", which are basically singletons without the drawbacks of the traditional singleton
pattern.
With dependency injection, each component just declares the collaborators it needs, and Spring
provides them at run time. The biggest advantage is that you can inject different instances for
different deployment scenarios of your application (e.g., staging versus production versus unit tests).
The Spring portfolio includes a lot of subprojects ranging from database access over security to cloud
services.
Spring Security
Spring Security is a powerful and highly customizable authentication and access-control
framework. It is the de-facto standard for securing Spring-based applications.
You can learn more about the core Spring Framework at Spring Framework Documentation.
Spring Boot makes it easy to create stand-alone, production-grade Spring based Applications
that you can "just run". We take an opinionated view of the Spring platform and third-party
libraries, so you can get started with minimum fuss. Most Spring Boot applications need very little
Spring configuration.
With Spring Boot, you get up and running with your Spring application in no time, without the need to
deploy to a container like Tomcat or Jetty. You can just run the application right from your IDE.
Spring Boot also ensures that you get a list of versions of libraries inside and outside the Spring
portfolio that are guaranteed to work together without problems.
You can learn more about Spring Boot from the excellent Spring Boot Reference Documentation.
1.4. Thymeleaf
Thymeleaf is a server-side Java template engine that uses natural templates to generate HTML pages.
Natural templates are HTML templates that can correctly be displayed in browsers and work as static
prototypes.
This book assumes you have a basic understanding of Thymeleaf. If you are not
familiar with it, consider reading Taming Thymeleaf first. It shows how to build a full
stack web application with Thymeleaf step-by-step.
Htmx is not tied to Thymeleaf. It is certainly possible to use other templating engines
that Spring supports such as Apache Freemarker or JTE.
1.5. Alpine
In some cases, we will want to have some client-side interactivity. We can use vanilla JavaScript for
this, but in some cases, it will be easier to use the Alpine library.
Like htmx, it uses attributes on the HTML to define the behavior. It shares what is called the Locality of
Behaviour principle:
The behaviour of a unit of code should be as obvious as possible by looking only at that unit of
code
In a nutshell, the goal of Tailwind is that you need almost no custom CSS. You apply ready-made
classes to your HTML.
It can be a bit overwhelming at the start to see many classes on your HTML when you use Tailwind
CSS. But give it the benefit of the doubt. Once you have used a bit, you will probably quite like it.
2.1. Prerequisites
To be able to create a Spring Boot application, we need to install Java and Maven as a build tool.
We will use Java 17, which is the minimal version that Spring Boot 3 needs. You can also use Java 21 if
you like.
2.1.1. macOS/Linux
Use SDKMAN! to install Java and Maven.
2. Install Java:
Use sdk list java to see a list of all possible Java versions that can be
installed.
3. Install Maven:
4. Run mvn --version to see if both are configured correctly. The output should look similar to this:
2.1.2. Windows
Use Chocolatey to install Java and Maven.
2. Install Java
3. Install Maven
4. Run mvn -v to see if both are configured correctly. The output should look similar to this:
2.2. ttcli
However, to quickly have a setup with live reload and support for htmx and Alpine, it is easier to use
ttcli.
This command line tool generates a Spring Boot with Thymeleaf project with the following things
configured automatically:
• Tailwind CSS compiler so that the proper CSS is created as we change our HTML.
• Live reload so we can quickly check any changes visually in the browser.
• Maven build calling npm so the frontend part of the application is properly built using a single
Maven command.
• Webjars dependencies for htmx and alpine (and optionally bootstrap if you prefer this over
Tailwind CSS).
2.2.2. Installation
A prerequisite is that you need to have npm installed for ttcli to work. For macOS
and Linux, the easiest is using nvm to install node and npm. Windows users can use
nvm-windows.
Open a terminal and run ttcli init. Answer the questions like this:
• Group: com.modernfrontendshtmx
• Artifact: modern-frontends-htmx
We will not be using htmx or Alpine for this little project, but I just add them here as
an example. You can also just generate the project without any web dependencies
for now.
After a few minutes (Depending on your internet speed, as it needs to download quite a few things),
the project is generated:
├── HELP.md
├── mvnw
├── mvnw.cmd
├── package-lock.json
├── package.json
├── pom.xml
├── postcss.config.js
├── src
│ ├── main
│ │ ├── java
│ │ │ └── com
│ │ │ └── modernfrontendshtmx
│ │ │ └── modernfrontendshtmx
│ │ │ ├── HomeController.java
│ │ │ └── ModernFrontendsHtmxApplication.java
│ │ └── resources
│ │ ├── application-local.properties
│ │ ├── application.properties
│ │ ├── static
│ │ │ └── css
│ │ │ └── application.css
│ │ └── templates
│ │ ├── index.html
│ │ └── layout
│ │ └── main.html
│ └── test
│ └── java
│ └── com
│ └── modernfrontendshtmx
│ └── modernfrontendshtmx
│ └── ModernFrontendsHtmxApplicationTests.java
└── tailwind.config.js
• package.json contains the npm scripts for building the client side of things. The 3 most
important scripts are build, build-prod and watch.
◦ build: builds the HTML, Javascript and CSS and copies it to the target/classes directory
where Spring Boot expects them.
◦ build-prod: builds everything just as build but will also minify the CSS and JS. For example,
run npm run build-prod and compare the generated CSS with running npm run build to
see the minification in action.
◦ watch: Watches the HTML, Javascript and CSS source files for changes and automatically runs
build when they changed.
• pom.xml contains the Maven configuation.
◦ It is configured so that running mvn clean verify will run the appropriate npm script as
well, so the application is fully built by just using Maven.
◦ It has exclusions for html, css, js and svg files as we will rely on the npm scripts to copy
those to the target directory.
◦ To build for production, use the release Maven profile: mvn verify -Prelease
• tailwind.config.js and postcss.config.js contain the configuration for Tailwind CSS and
PostCSS respectively.
• HomeController is a Spring MVC controller that serves the index.html Thymeleaf template
when accessing the root of the application through the browser (By default: http://localhost:8080)
• src/main/resources/static/css/application.css has the Tailwind standard CSS file
which only has 3 lines:
@tailwind base;
@tailwind components;
@tailwind utilities;
Those lines with the Tailwind CSS compiler will generate all the CSS you need.
The ttcli tool also generates an index.html and layout/main.html which are 2 starter
Thymeleaf templates.
Let’s add a bit of Tailwind CSS styling to the index.html. This is how it looks by default:
src/main/resources/templates/index.html
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/main}">
<body>
<div layout:fragment="content">
<!-- Add content here -->
<div>Welcome to your Spring Boot with Thymeleaf project!</div>
<div>See <code>HELP.md</code> for instructions on how to get
started.</div>
</div>
</body>
</html>
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/main}">
<body>
<div layout:fragment="content" class="m-4"> ①
<!-- Add content here -->
<div class="text-xl">Welcome to your Spring Boot with Thymeleaf
project!</div> ②
<div>See <code class="bg-yellow-500 px-2">HELP.md</code> for
instructions on how to get started.</div> ③
</div>
</body>
</html>
Start the Spring Boot application using the local profile. If you use IntelliJ IDEA, you can configure
this in the run configuration:
This is only available in IntelliJ IDEA Ultimate. I would strongly suggest getting that
version since it has support for Thymeleaf and JavaScript as well.
If you want to run from the command line, you would need to create a jar using mvn
verify and after that run the following command:
With the Java application running, open a terminal and run the following command:
This will automatically open your default browser at http://localhost:3000. It should look like this:
Now change the HTML by changing the text, or adding other Tailwind CSS classes and notice how your
browser will automatically reload and apply the changes.
Those plugins give you extra code completation and syntax highlighting for htmx and Alpine
respectively.
2.4. Summary
In this chapter, you learned:
If you ever get stuck following along, you can refer to the full source code on GitHub:
https://github.com/wimdeblauwe/modern-frontends-with-htmx-sources
It is important to understand the basic idea of htmx first. With htmx, you don’t use a JSON Data API.
Htmx expects the responses to be HTML fragments. It will react to certain events (e.g. button pressed,
input value changed) by issuing a network request. This can be GET, POST, DELETE, … The response
that the server gives back needs to be HTML. This HTML response will be swapped into the existing
HTML on the page dynamically.
To start, open a terminal and run ttcli init. Answer the questions like this:
• Group: com.modernfrontendshtmx
• Artifact: button-click-change
src/main/resources/index.html
If you have never used Tailwind CSS, the list of classes on the button might feel a bit
overwhelming. In an actual project, I would probably create a Thymeleaf fragment
that hides those classes away into a reusable component.
If you want to keep it really simple, you can add class="bg-black text-white"
instead of the full list to have a very basic button with white text on a black
background.
To make htmx work, we need to load the JavaScript library. Luckily for us, the ttcli has already
added the proper declaration in the layout/main.html file:
src/main/resources/templates/layout/main.html
<!DOCTYPE html>
<html th:lang="|${#locale.language}-${#locale.country}|"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title></title>
<link rel="stylesheet" th:href="@{/css/application.css}">
</head>
<body>
<main layout:fragment="content">
</main>
<script type="text/javascript"
th:src="@{/webjars/htmx.org/dist/htmx.min.js}"></script> ①
</body>
</html>
We use Webjars to load any JavaScript library in our project. See Using webjars with
Thymeleaf for a YouTube video that explains webjars in more depth.
We can now start adding htmx attributes in our HTML and experience the magic of htmx:
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/main}">
<body>
<div layout:fragment="content" class="container mx-auto my-4">
<div class="max-w-sm flex items-center gap-4">
<div id="message">
Hello World
</div>
• th:hx-get: htmx uses hx-get to indicate that we want an HTTP GET to happen to the given URL.
Because we want to use a Thymeleaf URL expression, we use the th: prefix. Any attribute that
Thymeleaf does not know will be rendered properly like this.
• hx-target: htmx needs to know where to put the HTML of the response. You can use hx-target
to specify various methods for determining where the response should go. In this example, we
use a CSS selector to indicate that the response should be swapped into the div with the
message id.
Since htmx will now issue a GET on /htmx, we need to make our application return some HTML at
that endpoint.
src/main/resources/templates/htmx.html
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org">
<body>
<div th:fragment="message">Hello htmx!</div>
</body>
</html>
package com.modernfrontendshtmx.buttonclickchange;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/")
public class HomeController {
@GetMapping
public String index(Model model) {
return "index";
}
@GetMapping("/htmx") ①
public String htmx() {
return "htmx :: message"; ②
}
}
You can now start the application. It should look like this:
If you click on the button, the text will change to "Hello htmx!".
To better understand what is going on, take a look at the network tab of the browser developer tools.
Notice the request sent out by htmx and the HTML response that our server returns.
3.2. Triggers
In our example, the request to the server was triggered when the user clicks on the button. This is
default htmx behavior, so we did not have to specify anything in our html. We could have made it
explicit by providing the hx-trigger attribute like this:
<button class="..."
th:hx-get="@{/htmx}"
hx-target="#message"
hx-trigger="click">
Click me
</button>
If we want to have a different trigger, we can use the hx-trigger attribute to configure this.
The keyup event is triggered whenever the user types into the input. However, there are 2 trigger
modifiers following.
The changed modifier indicates that the request should only be triggered if the value of the input has
actually changed. Just navigating inside the input using the arrow keys, for example, should not trigger
a new request.
The delay:500ms modifier will wait 500 milliseconds to send the request. If another keyup changed
event has happened during that time, it will replace the first request.
Another example with keyup is triggering a request when a keyboard shortcut is pressed. By applying
a trigger filter using the [] notation, we can have htmx only issue a request upon a selected key
combination:
In this example, the user needs to press the SHIFT key together with the K key to have the request
fired. We also use the from:body modifier to ensure the keyboard shortcut works wherever the
current focus is on the page.
A first example is the load event that triggers the request when the page is loaded:
This is an interesting event if you have a dashboard for example, where you will asynchronously load
each section of the dashboard upon page load. This will certainly increase the perceived speed of your
dashboard to the user.
The load event can also be combined with the delay modifier:
The request will now be done 2 seconds after the page has loaded. If the HTML that returns from the
server again contains load delay:2s, then again a request is done to the server after 2 seconds.
This can be interesting to show a progress bar for example. The progress bar can keep progressing
and when the long-running process is finally done, a response can be returned that no longer has the
load delay:2s and the polling would stop.
If you have a big page, and you only want to have the request fired when the user scrolls down to
actually view the content, you can use the revealed event trigger.
This event is fired once when an element first scrolls into the viewport.
This will have htmx trigger a request to the server every second.
3.2.4. Example
Replace the index.html in our hello world demo application with this code to view the different
triggers in action:
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/main}">
<body>
<div layout:fragment="content" class="container mx-auto max-w-2xl mt-4">
<div class="float-right bg-amber-400 p-2">
<div id="message">
Request info
</div>
</div>
<h1 class="text-2xl mb-4">Trigger Demo</h1>
<h2 class="text-xl mb-2">Standard Events</h2>
<div class="mb-4">
<div class="text-sm">Button is triggered by the <code
class="font-mono">click</code> event.</div>
<div class="mb-4">
<div class="text-sm">Input, textarea and select fields are
triggered by the <code
class="font-mono">change</code> event
(For input and textarea, this triggers when you move the
focus out of the input).
</div>
<div>
<div>
<div>Input</div>
<input id="my-text-input-id"
type="text"
name="my-text-input-name"
class="mb-2"
th:hx-get="@{/htmx}"
hx-target="#message">
</div>
<div>
<div>Textarea</div>
<textarea
th:hx-get="@{/htmx}"
hx-target="#message"></textarea>
</div>
<div>Select</div>
<select th:hx-get="@{/htmx}"
hx-target="#message">
<option>Choice 1</option>
<option>Choice 2</option>
<option>Choice 3</option>
</select>
</div>
</div>
<div class="mb-4">
<div class="text-sm">Forms are triggered by the <code
class="font-mono">submit</code> event.
</div>
<form th:hx-get="@{/htmx}"
hx-target="#message">
<input type="text"
name="form-text-input">
<button class="rounded bg-indigo-600 px-2 py-1 text-sm font-
semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline
focus-visible:outline-2 focus-visible:outline-offset-2 focus-
visible:outline-indigo-600"
type="submit">Submit
</button>
<button class="rounded bg-white px-2 py-1 text-sm font-
semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300
hover:bg-gray-50"
type="reset">Reset
</button>
</form>
</div>
<div class="mb-4">
<div class="text-sm">Everything else is triggered by the <code
class="font-mono">click</code> event.
</div>
<div class="bg-gray-200 p-2 cursor-pointer"
th:hx-get="@{/htmx}"
hx-target="#message">
I am a div you can click on to trigger a htmx request.
</div>
</div>
hx-target="this"
hx-trigger="keyup[shiftKey&&key=='K'] from:body">
Press SHIFT-K to have the div replaced by the htmx response.
</div>
</div>
To have the proper styling, we also need to add the Tailwind CSS forms plugin. Stop the live reload if
you have it running and run this command to install the forms plugin:
npm i @tailwindcss/forms -D
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org">
<body>
<div th:fragment="message">A request was triggered at <span
th:text="${currentTime}"></span></div>
</body>
</html>
By sending back the current time, it will be clearer on the example page that something has changed.
To support this Thymeleaf page, we need to add the currentTime attribute in our model:
com.modernfrontendshtmx.buttonclickchange.HomeController
@GetMapping("/htmx")
public String htmx(@RequestParam(value = "delay", required = false)
Integer delayInSeconds,
Model model,
HttpServletRequest request) throws
InterruptedException {
String elementId = request.getHeader("Hx-Trigger");
System.out.println("elementId = " + elementId);
if (delayInSeconds != null) {
Thread.sleep(delayInSeconds * 1000L);
}
model.addAttribute("currentTime", LocalTime.now()); ①
return "htmx :: message";
}
After these changes, restart the Spring Boot application and run npm run build && npm run
watch to make sure everything is up-to-date.
3.3. Targets
When htmx does a request, it needs to know what element on the page needs to be swapped with the
response of the request. This is done by specifying the hx-target attribute. Htmx has the following
options:
• Use a CSS selector. In the Trigger Demo application, we used #message to target the html element
with the message id.
If the CSS selector returns multiple elements, only the first one will be swapped.
• Use this: This indicates that the current element where the htmx attributes are defined on, will
be the target of the swap.
• Use closest <CSS selector> to find the closest parent element. For example:
<table>
<tr>
<td>Wim</td>
<td>Deblauwe</td>
<td><button hx-target="closest tr" hx-get="/1/refresh"
>Refresh</button></td>
</tr>
...
</table>
Every button can just have closest tr and the /1/refresh can return the whole row of data to
have that row updated. Without this, you would need to put id’s on every row to target them.
• Use next <CSS selector> to find the next element in the DOM matching the given CSS
selector.
• Use previous <CSS selector> to find the previous element in the DOM the given CSS selector.
• Use find <CSS selector> which will find the first child descendant element that matches the
given CSS selector. (e.g find tr would target the first child descendant row to the element)
3.4. Swapping
It should be clear by now that htmx allows to swap elements from the HTML page with new content
that is coming from the server. By default, the swap is done by replacing the inner HTML with the new
content. This default behavior can be changed by setting the hx-swap attribute.
In each of the examples, it is assumed that the server returns this HTML snippet:
• innerHTML: This is the default, replacing the content inside of the target element.
Starting from:
<div hx-get="/"
hx-target="this"
hx-swap="innerHTML">
<div>Hello htmx</div>
</div>
<div hx-get="/"
hx-target="this"
hx-swap="innerHTML">
<div>This is the server response</div>
</div>
• outerHTML: Replaces the entire target element with the returned content
Starting from:
<div hx-get="/"
hx-target="this"
hx-swap="outerHTML">
<div>Hello htmx</div>
</div>
• afterbegin: The server response is added before the first child inside the target
Starting from:
<div hx-get="/"
hx-target="#list"
hx-swap="afterbegin">
<div>Hello htmx</div>
</div>
<div id="parent">
<div id="list">
<div>Child 1</div>
<div>Child 2</div>
</div>
</div>
<div hx-get="/"
hx-target="#list"
hx-swap="afterbegin">
<div>Hello htmx</div>
</div>
<div id="parent">
<div id="list">
<div>This is the server response</div>
<div>Child 1</div>
<div>Child 2</div>
</div>
</div>
• beforebegin: Prepends the content before the target in the target’s parent element
Starting from:
<div hx-get="/"
hx-target="#list"
hx-swap="beforebegin">
<div>Hello htmx</div>
</div>
<div id="parent">
<div id="list">
<div>Child 1</div>
<div>Child 2</div>
</div>
</div>
<div hx-get="/"
hx-target="#list"
hx-swap="beforebegin">
<div>Hello htmx</div>
</div>
<div id="parent">
<div>This is the server response</div>
<div id="list">
<div>Child 1</div>
<div>Child 2</div>
</div>
</div>
• beforeend: Appends the content after the last child inside the target
Starting from:
<div hx-get="/"
hx-target="#list"
hx-swap="beforeend">
<div>Hello htmx</div>
</div>
<div id="parent">
<div id="list">
<div>Child 1</div>
<div>Child 2</div>
</div>
</div>
<div hx-get="/"
hx-target="#list"
hx-swap="beforeend">
<div>Hello htmx</div>
</div>
<div id="parent">
<div id="list">
<div>Child 1</div>
<div>Child 2</div>
<div>This is the server response</div>
</div>
</div>
• afterend: Appends the content after the target in the target’s parent element
Starting from:
<div hx-get="/"
hx-target="#list"
hx-swap="afterend">
<div>Hello htmx</div>
</div>
<div id="parent">
<div id="list">
<div>Child 1</div>
<div>Child 2</div>
</div>
</div>
<div hx-get="/"
hx-target="#list"
hx-swap="afterend">
<div>Hello htmx</div>
</div>
<div id="parent">
<div id="list">
<div>Child 1</div>
<div>Child 2</div>
</div>
<div>This is the server response</div>
</div>
Starting from:
<div hx-get="/"
hx-target="#list"
hx-swap="delete">
<div>Hello htmx</div>
</div>
<div id="parent">
<div id="list">
<div>Child 1</div>
<div>Child 2</div>
</div>
</div>
<div hx-get="/"
hx-target="#list"
hx-swap="delete">
<div>Hello htmx</div>
</div>
<div id="parent">
</div>
3.6. Summary
In this chapter, we’ve explored the fundamental aspects of htmx. We’ve learned about the essential
features and properties that will be the foundation for our further learning in the upcoming chapters.
These basic building blocks will be crucial as we delve deeper into the material ahead in this book,
helping us understand how htmx works and how to make the most of it.
The library uses some HTTP request and response headers which can be used to build some more
advanced functionality.
HX-Current-URL
The current URL of the browser at the time of the request.
HX-History-Restore-Request
true if the request is for history restoration after a miss in the local history cache.
HX-Prompt
The user response to an hx-prompt
HX-Request
Always true.
HX-Target
The id of the target element if it exists.
HX-Trigger-Name
The name of the triggered element if it exists.
HX-Trigger
The id of the triggered element if it exists.
We can see those headers quite easily by opening up the developer tools of our browser. If you have
an input like this on your page:
<input id="my-text-input-id"
type="text"
name="my-text-input-name"
class="mb-2"
th:hx-get="@{/htmx}"
hx-target="#message">
We can use those headers to influence the behavior of the backend if we choose to do so.
For instance, since every request that htmx makes has the HX-Request header set, we can use this to
only expose endpoints to htmx. If that same URL is tried normally in the browser, it would return a
404.
The way to do that is using the headers property of the @RequestMapping annotation (or one of the
more specialized ones like @GetMapping, @PostMapping, …).
For example:
① Use the HX-Request header to only allow htmx to call this endpoint.
Other request headers can be read by using @RequestHeader on your controller method:
HX-Location
Allows you to do a client-side redirect that does not do a full page reload.
HX-Push-Url
Pushes a new url into the history stack.
HX-Redirect
Can be used to do a client-side redirect to a new location
HX-Refresh
If set to true the client side will do a a full refresh of the page.
HX-Replace-Url
Replaces the current URL in the location bar
HX-Reswap
Allows you to specify how the response will be swapped. In most cases, you set the swapping
behavior via the hx-swap HTML attribute on your element. Using this response header, you can
override this from the backend.
HX-Retarget
A CSS selector that updates the target of the content update to a different element on the page.
This overrides the hx-target on the HTML element itself.
HX-Trigger
Allows you to trigger client side events. Those events can be used by other elements on the page to
update themselves for example. This can be done via hx-trigger="my-custom-event", or via
client-side scripting (vanilla JavaScript, AlpineJS, …).
HX-Trigger-After-Settle
Allows you to trigger client side events after the settling step.
HX-Trigger-After-Swap
Allows you to trigger client side events after the swap step.
Setting those response headers can be done by injecting HttpServletResponse in your controller
method:
// ...
response.setHeader("HX-Trigger", "itemAdded"); ②
// ...
}
On the client side, we can use some JavaScript to react to the event:
document.body.addEventListener("itemAdded", function(evt){
alert("An item was added!");
})
We can also use the hx-trigger attribute to react to the event by doing a new request:
In this example, the div will listen to the itemAdded client-side event that is done from any element
that is a child element of body (Thanks to event bubbling). If such an event is received, a GET request
is done to /item-count.
This is a nice way to keep independent pieces on the page in sync. One element sends out an event
and the other part can react to that and update its state.
The library started as a joint effort between Oliver Drotbohm, Clint Checketts and Wim Deblauwe and
is now available as an open-source project where everybody can contribute.
To add the library, add the following dependency to your Maven pom.xml:
<dependency>
<groupId>io.github.wimdeblauwe</groupId>
<artifactId>htmx-spring-boot-thymeleaf</artifactId>
<version>LATEST_VERSION_HERE</version>
</dependency>
There is the htmx-spring-boot artifact which can be used for any templating engine, but here we
use the Thymeleaf specific variant: htmx-spring-boot-thymeleaf.
By using this libary, we can use the @HxRequest annotation to indicate that only htmx should call the
endpoint.
The example:
becomes:
@GetMapping("/active-items-count")
@HxRequest
public String htmxActiveItemsCount(Model model) {
model.addAttribute("numberOfActiveItems", getNumberOfActiveItems());
@GetMapping
@HxRequest
public String htmxRequestDetails(HtmxRequest htmxReq) { ①
if(htmxReq.isHistoryRestoreRequest()) { ②
// ...
}
// ...
}
① Inject HtmxRequest.
② Use the methods on HtmxRequest to know what htmx related request headers have been set.
The library also supports response headers. Starting from this example:
// ...
response.setHeader("HX-Trigger", "itemAdded");
// ...
}
@PostMapping("/")
@HxRequest
@HxTrigger("itemAdded")
public String htmxAddMethod(Model model) {
// ...
}
The library also includes a custom Thymeleaf processor to make the templates a bit nicer.
<button class="destroy"
th:hx-delete="@{/{id}(id=${item.id})}"
th:hx-target="|#list-item-${item.id}|"
hx-trigger="click"
hx-swap="outerHTML">
Delete
</button>
With the custom processor, you can use hx: as a prefix to have Thymeleaf processing:
<button class="destroy"
hx:delete="@{/{id}(id=${item.id})}"
hx:target="|#list-item-${item.id}|"
hx-trigger="click"
hx-swap="outerHTML">
Delete
</button>
There are quite some more things that can be done with the library, which we will see in the
upcoming chapters. If you are eager to learn more, check out the documentation.
4.4. Summary
This chapter explained the request and response headers that htmx uses. It also showed basic usage
of the htmx-spring-boot-thymeleaf library to make it easier to work with those headers in a Spring
Boot with Thymeleaf application.
• Group: com.modernfrontendshtmx
• Artifact: todomvc
To keep things simple on the backend, we will use an h2 in-memory database and Spring Data JPA to
persist the todo items.
pom.xml
<project>
...
<dependencies>
...
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<groupId>org.webjars.npm</groupId>
<artifactId>todomvc-common</artifactId>
<version>1.0.5</version>
</dependency>
<dependency>
<groupId>org.webjars.npm</groupId>
<artifactId>todomvc-app-css</artifactId>
<version>2.4.1</version>
</dependency>
...
</dependencies>
...
com.modernfrontendshtmx.todomvc.todo.TodoItem
package com.modernfrontendshtmx.todomvc.todo;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.validation.constraints.NotBlank;
@Entity
public class TodoItem {
@Id
@GeneratedValue
private Long id;
@NotBlank
private String title;
protected TodoItem() {
com.modernfrontendshtmx.todomvc.todo.TodoItemNotFoundException
package com.modernfrontendshtmx.todomvc.todo;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.NOT_FOUND)
public class TodoItemNotFoundException extends RuntimeException {
public TodoItemNotFoundException(Long id) {
super(String.format("TodoItem with id %s not found", id));
}
}
com.modernfrontendshtmx.todomvc.todo.TodoItemRepository
package com.modernfrontendshtmx.todomvc.todo;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public class TodoItemRepository {
private final SpringDataJpaTodoItemRepository repository;
public TodoItemRepository(SpringDataJpaTodoItemRepository
repository) {
this.repository = repository;
}
com.modernfrontendshtmx.todomvc.todo.SpringDataJpaTodoItemRepository
package com.modernfrontendshtmx.todomvc.todo;
import org.springframework.data.repository.ListCrudRepository;
import java.util.List;
Next, create a web package inside the todo package with the TodoItemController and
TodoItemFormData classes:
com.modernfrontendshtmx.todomvc.todo.web.TodoItemController
package com.modernfrontendshtmx.todomvc.todo.web;
import com.modernfrontendshtmx.todomvc.todo.TodoItem;
import com.modernfrontendshtmx.todomvc.todo.TodoItemNotFoundException;
import com.modernfrontendshtmx.todomvc.todo.TodoItemRepository;
import jakarta.validation.Valid;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
@Controller
@RequestMapping("/")
public class TodoItemController {
@GetMapping
public String index(Model model) {
addAttributesForIndex(model, ListFilter.ALL);
return "index";
}
@GetMapping("/active")
public String indexActive(Model model) {
addAttributesForIndex(model, ListFilter.ACTIVE);
return "index";
}
@GetMapping("/completed")
public String indexCompleted(Model model) {
addAttributesForIndex(model, ListFilter.COMPLETED);
return "index";
}
@PostMapping
public String addNewTodoItem(@Valid @ModelAttribute("item")
TodoItemFormData formData) {
repository.save(new TodoItem(formData.getTitle(), false));
return "redirect:/";
}
@PutMapping("/{id}/toggle")
public String toggleSelection(@PathVariable("id") Long id) {
TodoItem todoItem = repository.findById(id)
.orElseThrow(() -> new TodoItemNotFoundException(id));
todoItem.setCompleted(!todoItem.isCompleted());
repository.save(todoItem);
return "redirect:/";
}
@PutMapping("/toggle-all")
public String toggleAll() {
List<TodoItem> todoItems = repository.findAll();
for (TodoItem todoItem : todoItems) {
todoItem.setCompleted(!todoItem.isCompleted());
repository.save(todoItem);
}
return "redirect:/";
}
@DeleteMapping("/{id}")
public String deleteTodoItem(@PathVariable("id") Long id) {
repository.deleteById(id);
return "redirect:/";
}
@DeleteMapping("/completed")
com.modernfrontendshtmx.todomvc.todo.web.TodoItemFormData
package com.modernfrontendshtmx.todomvc.todo.web;
import jakarta.validation.constraints.NotBlank;
This is all for the Java side of things. Next, replace the index.html that was generated with this code:
src/main/resources/templates/index.html
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Template • TodoMVC</title>
<link rel="stylesheet" th:href="@{/webjars/todomvc-
common/base.css}">
<link rel="stylesheet" th:href="@{/webjars/todomvc-app-
css/index.css}">
</head>
<body>
<section class="todoapp">
<header class="header">
<h1>todos</h1>
<form th:action="@{/}" method="post" th:object="${item}">
<input class="new-todo" placeholder="What needs to be done?"
autofocus
th:field="*{title}">
</form>
</header>
<!-- This section should be hidden by default and shown when there
are todos -->
<section class="main" th:if="${totalNumberOfItems > 0}">
<form th:action="@{/toggle-all}" th:method="put">
<input id="toggle-all" class="toggle-all" type="checkbox"
onclick="this.form.submit()">
<label for="toggle-all">Mark all as complete</label>
</form>
<ul class="todo-list" th:remove="all-but-first">
<li th:insert="~{fragments :: todoItem(${item})}"
th:each="item : ${todos}" th:remove="tag">
</li>
<!-- These are here just to show the structure of the list
items -->
<!-- List items should get the class `editing` when editing
and `completed` when marked as completed -->
<li class="completed">
<div class="view">
<input class="toggle" type="checkbox" checked>
<label>Taste JavaScript</label>
<button class="destroy"></button>
</div>
<input class="edit" value="Create a TodoMVC template">
</li>
<li>
<div class="view">
<input class="toggle" type="checkbox">
<label>Buy a unicorn</label>
<button class="destroy"></button>
</div>
<input class="edit" value="Rule the web">
</li>
</ul>
</section>
<!-- This footer should be hidden by default and shown when there
are todos -->
<footer class="footer" th:if="${totalNumberOfItems > 0}">
<!-- This should be `0 items left` by default -->
<th:block th:unless="${numberOfActiveItems == 1}">
<span class="todo-count"><strong
th:text="${numberOfActiveItems}">0</strong> items left</span>
</th:block>
<th:block th:if="${numberOfActiveItems == 1}">
<span class="todo-count"><strong>1</strong> item left</span>
</th:block>
<ul class="filters">
<li>
<a th:href="@{/}"
th:classappend="${filter.name() ==
'ALL'?'selected':''}">All</a>
</li>
<li>
<a th:href="@{/active}"
th:classappend="${filter.name() ==
'ACTIVE'?'selected':''}">Active</a>
</li>
<li>
<a th:href="@{/completed}"
th:classappend="${filter.name() ==
'COMPLETED'?'selected':''}">Completed</a>
</li>
</ul>
<!-- Hidden if no completed items are left ↓ -->
<form th:action="@{/completed}" th:method="delete"
th:if="${numberOfCompletedItems > 0}">
<button class="clear-completed">Clear completed</button>
</form>
</footer>
</section>
<footer class="info">
<p>Double-click to edit a todo</p>
<!-- Remove the below line ↓ -->
<p>Template by <a href="http://sindresorhus.com">Sindre
Sorhus</a></p>
<!-- Change this out with your name and url ↓ -->
<p>Created by <a href="http://todomvc.com">you</a></p>
src/main/resources/templates/fragments.html
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
lang="en">
<!--/*@thymesVar id="item"
type="com.wimdeblauwe.examples.todomvcthymeleaf.todoitem.web.TodoItemCon
troller.TodoItemDto"*/-->
<li th:fragment="todoItem(item)"
th:classappend="${item.completed?'completed':''}">
<div class="view">
<form th:action="@{/{id}/toggle(id=${item.id})}" th:method=
"put">
<input class="toggle" type="checkbox"
onchange="this.form.submit()"
th:attrappend=
"checked=${item.completed?'true':null}">
<label th:text="${item.title}">Taste JavaScript</label>
</form>
<form th:action="@{/{id}(id=${item.id})}" th:method="delete">
<button class="destroy"></button>
</form>
</div>
<input class="edit" value="Create a TodoMVC template">
</li>
</html>
spring.mvc.hiddenmethod.filter.enabled=true
Remove the generated HomeController and layout/main.html as we will not be using those.
Start the Spring Boot application with the local profile and run npm run build.
Try adding a few todo items and completing them. Notice the "x items left" in the bottom left corner
changing as items are changed. Also use the "All"/"Active"/"Completed" filtering and notice how that
changes the URL in the web browser.
After playing around with the application a bit, it might look like this:
Do note that for every interaction we do with the application, there is a page reload happening. The
advantage of this is that the browser will show a progress indicator while we wait for the response of
the server. The drawback is that this does not quite feel like a modern web application.
If the code of this implementation is unclear, you might want to read my other book
Taming Thymeleaf first. It explains in great detail all the things you need to know to
understand this.
You can also have a look at my blog entry TodoMVC with Spring Boot and Thymeleaf
which explains the different parts in more detail, but the code there is an older
version using Spring Boot 2.
To fix this, add the following to index.html (just before the closing of the <body> tag):
<script type="text/javascript"
th:src="@{/webjars/htmx.org/dist/htmx.min.js}"></script>
We will start with one of the easiest ways to turn your Multi Page Application (MPA) into a Single Page
Application (SPA) by "boosting" regular HTML anchors and forms using hx-boost. We can add hx-
boost to the top-level element of our page, and HTMX will intercept the form submits, turn them into
AJAX requests and use the response HTML to dynamically change the current page without a page
refresh. There is no need to change anything on the server side, the redirects can just stay in place.
Htmx will handle them properly.
<section class="todoapp">
with:
There is an onchange handler that submits the form when the completion state of the todo item is
toggled. We need to slightly alter that handler to make it work with htmx. Replace
onchange="this.form.submit()" with onchange="this.form.requestSubmit()":
src/main/resources/templates/fragments.html
<input th:id="|toggle-checkbox-${item.id}|"
class="toggle"
type="checkbox"
onchange="this.form.requestSubmit()"
th:attrappend="checked=${item.completed?'true':null}">
Restart the application and notice how the browser never reloads the page, everything seems to
happen as if this was a completely JavaScript built application.
The filters at the bottom that filter on all items, active items and completed items also work fine. Htmx
will also update the URL in the browser to reflect that path that the browser would normally redirect
to.
This works well, but there is one drawback: we are still sending the complete page’s HTML over the
network. We can do a more fine-grained implementation so that we only send the HTML that we want
to swap.
You can leave the hx-boost if you want, but it is a bit easier to see the changes in
behaviour if you remove it. But for your own applications, you can certainly start by
adding hx-boost and gradually migrate to use a fine-grained implementation for
some parts.
By removing this, we are back to our normal Thymeleaf MPA with page reloads for any action we do.
We’ll start the implementation by making sure there is no page reload to add a todo item.
This is the current implementation of the form to add a new todo item:
We will change the HTML to add some htmx attributes on the <input> field:
We have seen those 4 htmx attributes in the previous chapters, but I will recap them here for
convenience:
• hx-trigger: htmx will do the request when the Enter key is pressed.
• hx-target: The HTML response of the POST request should be added to the HTML element with
Pressing Enter will send a POST request to our Spring Boot backend with the value of the text input.
The current @PostMapping method in our controller will react to that, but the problem is that it sends
back a redirect at the end. This is perfectly fine in a normal Thymeleaf application, but not what we
want here. We want to receive the HTML snippet that represents one todo item, so we can add it to
the #todo-list div on the page dynamically.
Each request that htmx does includes an HX-Request: true request header. We can make use of
this to define a second method in the TodoItemController that will only trigger for a POST to / if
that request is issued via htmx:
com.modernfrontendshtmx.todomvc.todo.web.TodoItemController
@PostMapping
@HxRequest①
public String htmxAddTodoItem(TodoItemFormData formData,
Model model) {
TodoItem item = repository.save(new TodoItem(formData.getTitle(),
false)); ②
model.addAttribute("item", toDto(item)); ③
① We want this method to react to a POST on /, but only when it is an htmx request. Technically,
when the HX-Request header is set, but the htmx-spring-boot library makes it nicer by providing
the @HxRequest annotation.
To support the controller method, update the save method of TodoItemRepository to return the
saved TodoItem:
What is very nice here is that we can re-use our fragment we already are using to display the list of
current todo items during the normal rendering of the page:
<ul class="todo-list">
<li th:insert="~{fragments :: todoItem(${item})}" th:each="item :
${todos}" th:remove="tag">
</li>
</ul>
Like that, we are sure dynamically added todo items and initially loaded todo items are displayed
exactly the same.
Add the id todo-list to that <ul> element since our hx-target refers to that to dynamically add
the response HTML we get back from the controller:
If you try it now, you still get a page refresh, because pressing Enter still submits the form. To fix that,
add this JavaScript snippet:
<script>
document.getElementById('new-todo-form').addEventListener('submit',
function (evt) {
evt.preventDefault();
})
</script>
There are 2 alternatives to adding that JavaScript snippet to disable the form.
1. We can remove the <form> element completely, and it would also still work. But
with the current setup, the form is used when JavaScript is disabled. And htmx is
used when JavaScript is enabled.
2. It is also possible to add the hx-… attributes on the <form> itself like this:
hx-target="#todo-list"
hx-swap="beforeend"
hx-post="/">
<input id="new-todo-input" class="new-todo"
placeholder="What needs to be done?" autofocus
autocomplete="false"
name="title"
th:field="*{title}"
>
</form>
In that case, HTMX will disable the form submission and we don’t have to do it in
JavaScript manually.
That said, we are still not there yet. The main section that contains the list of todo items has this in the
template:
On startup, there are no todo items, so the main section is never rendered in the template. What we
need to do is: make sure it is present so htmx can target it and only display it when there is at least 1
actual todo item.
Finally, after a submit, the page is no longer reloaded, so the input is no longer cleared. We now need
to handle this in JavaScript:
<script>
htmx.on('#new-todo-input', 'htmx:afterRequest', function (evt) { ①
evt.detail.elt.value = ''; ②
});
</script>
① Register a callback function that is triggered after each request that happens on the new-todo-
input item.
② Set the value to the empty string on the element that triggered the callback, effectively clearing out
the text input.
Now that the main section and the footer are rendered but invisible at the start when there are no
todo items, we need to show them as soon as there is a todo item.
<script>
htmx.on('htmx:afterSwap', function (evt) { ①
let items = document.querySelectorAll('#todo-list li'); ②
let mainSection = document.getElementById('main-section');
let mainFooter = document.getElementById('main-footer');
if (items.length > 0) { ③
mainSection.classList.remove('hidden');
mainFooter.classList.remove('hidden');
} else {
mainSection.classList.add('hidden');
mainFooter.classList.add('hidden');
}
});
</script>
① Define a callback function that is called each time htmx does a swap in the DOM tree.
② Count the number of <li> items in the todo-list element.
③ Check if there are todo items or not to add or remove the hidden CSS class.
An alternative implemention would be to target a bigger part of the HTML and return
not only the HTML for the todo item itself, but also include the full main section and
footer. I found this approach here to be nicer, as the HTML snippet returned from
the controller method only contains the <li> that renders the todo item itself. Even
if I had to write this small snippet of JavaScript to make it work.
Try it out. You should see no page reloads when adding todo items. The nice thing is that everything
else still works fine, it keeps using the normal page reloads for the parts we did not touch upon.
The following diagram shows how we want to make this happen. The todo-list and active-
items-count are both id’s of <div> elements on the HTML page.
Client Server
4 GET /active-items-count
First, the todo-list sends out a POST request. The backend responds with the HX-Trigger:
itemAdded header set. This triggers a client-side event that the active-items-count <div> will
listen to and in turn, trigger a new request. The result of that request is swapped into the DOM and
shows the updated count of the todo items.
To implement this, we start by extracting the HTML that shows the number of active items into a
Thymeleaf fragment. This is the current implementation:
src/main/resources/templates/index.html
...
<th:block th:unless="${numberOfActiveItems == 1}">
<span class="todo-count"><strong
th:text="${numberOfActiveItems}">0</strong> items left</span>
</th:block>
<th:block th:if="${numberOfActiveItems == 1}">
<span class="todo-count"><strong>1</strong> item left</span>
</th:block>
We can copy it into a fragment called active-items-count and add some htmx attributes to have it
refresh automatically:
src/main/resources/templates/fragments.html
<span th:fragment="active-items-count"
hx:get="@{/active-items-count}"
hx-swap="outerHTML"
hx-trigger="itemAdded from:body">
<th:block th:unless="${numberOfActiveItems == 1}">
<span class="todo-count"><strong
th:text="${numberOfActiveItems}">0</strong> items left</span>
</th:block>
<th:block th:if="${numberOfActiveItems == 1}">
So whenever there is an itemAdded event sent from any element on the page, these 2 attributes will
ensure that there will be an automatic GET request to update the number of items. The response of
the GET returns the HTML snippet that will be used to replace itself in the DOM. To be clear, this event
is something that happens inside the browser, not on the server side.
src/main/resources/templates/index.html
...
<footer id="main-footer" class="footer"
th:classappend="${totalNumberOfItems == 0?'hidden':''}">
<!-- This should be `0 items left` by default -->
<span th:replace="~{fragments :: active-items-count}"></span>
We want the event to be sent when a new item is added. We do this by adding a special header HX-
Trigger in the response. This header instructs htmx to send out an event client-side when it receives
this header in a response.
com.modernfrontendshtmx.todomvc.todo.web.TodoItemController
@PostMapping
@HxRequest
@HxTrigger("itemAdded") ①
public String htmxAddTodoItem(TodoItemFormData formData,
Model model) {
TodoItem item = repository.save(new TodoItem(formData.getTitle(),
false));
model.addAttribute("item", toDto(item));
① Use @HxTrigger to send back itemAdded as the value of the HX-Trigger response header.
By returning the header, htmx will trigger the itemAdded event, which is caught by our little fragment,
and it will perform a GET on /active-items-count.
@GetMapping("/active-items-count")
@HxRequest
public String htmxActiveItemsCount(Model model) {
model.addAttribute("numberOfActiveItems", getNumberOfActiveItems());
The implementation is fairly straightforward. Do notice how extracting the fragment makes the
implementation easy here. We just use the fragment to render the proper HTML snippet.
With this in place, the number of active items is updated properly whenever a new item is added
without page refresh.
Start by adding a new controller method to toggle the completion state of the todo item:
com.modernfrontendshtmx.todomvc.todo.web.TodoItemController
@PutMapping("/{id}/toggle")
@HxRequest ①
@HxTrigger("itemCompletionToggled") ②
public String htmxToggleTodoItem(@PathVariable("id") Long id,
Model model) {
TodoItem todoItem = repository.findById(id)
.orElseThrow(() -> new TodoItemNotFoundException(id));
todoItem.setCompleted(!todoItem.isCompleted());
repository.save(todoItem);
model.addAttribute("item", toDto(todoItem)); ③
① The @HxRequest annotation ensures this method is only called for requests done by htmx.
③ After toggling the todo item, add the DTO to the Model so the fragment can render properly with
the information from the DTO.
④ Use the Thymeleaf fragment to send the HTML snippet back to the browser.
src/main/resources/templates/fragments.html
<li th:fragment="todoItem(item)"
th:classappend="${item.completed?'completed':''}">
<div class="view">
<form th:action="@{/{id}/toggle(id=${item.id})}" th:method=
"put">
<input class="toggle" type="checkbox"
onchange="this.form.requestSubmit()"
th:attrappend=
"checked=${item.completed?'true':null}">
<label th:text="${item.title}">Taste JavaScript</label>
</form>
<form th:action="@{/{id}(id=${item.id})}" th:method="delete">
<button class="destroy"></button>
</form>
</div>
<input class="edit" value="Create a TodoMVC template">
</li>
with this:
src/main/resources/templates/fragments.html
<li th:fragment="todoItem(item)"
th:classappend="${item.completed?'completed':''}" th:id="|list-item-
${item.id}|">
<div class="view">
<input th:id="|toggle-checkbox-${item.id}|" class="toggle"
type="checkbox"
th:attrappend="checked=${item.completed?'true':null}"
hx:put="@{/{id}/toggle(id=${item.id})}"
hx:target="|#list-item-${item.id}|"
hx-trigger="click"
hx-swap="outerHTML"
>
<label th:text="${item.title}">Taste JavaScript</label>
<form th:action="@{/{id}(id=${item.id})}" th:method="delete">
<button class="destroy"></button>
</form>
</div>
</li>
1. Remove the <form> around the <input> as we will use htmx now and no longer a form submit.
2. An id is added on the <li> item. This is needed as htmx will replace the complete <li> item with
an updated one that it will receive as a response to the AJAX call. Htmx needs the id to be able to
know which <li> it needs to replace.
3. Add the hx-trigger="click" attribute, so htmx will start to do its work when the <input> item
is clicked.
4. Add the hx-swap="outerHTML" attribute so HTMX will replace the current <li> completely with
the received <li> snippet in the AJAX response. Remember that by default, htmx uses innerHTML
which would make the response a child element of the target element.
5. Add hx:put=… so that a PUT request is done. We need to use hx:put so we can use the item
parameter of the Thymeleaf fragment to dynamically build the correct URL to use.
6. Add hx:target=… to point to the id of the <li> element. This instructs htmx to use that element
as the target for replacement.
With this code in place, we can toggle the state of the todo item without a page refresh. We just need
to do one last thing to also make the number of active items correct when we toggle.
...
hx-trigger="itemAdded from:body"
Due to the response header we have added on the Java side when toggling the request, an
itemCompletionToggled event is fired client side. By changing the trigger to also include that event,
we make everything work again:
...
hx-trigger="itemAdded from:body, itemCompletionToggled from:body"
The pattern should probably start to become familiar by now. We start with our controller method:
com.modernfrontendshtmx.todomvc.todo.web.TodoItemController
@DeleteMapping("/{id}")
@HxRequest
@ResponseBody ①
@HxTrigger("itemDeleted") ②
return ""; ③
}
① We need to return an empty body as we want to replace the <li> item on the HTML page with
nothing. The @ResponseBody annotation avoids that Thymeleaf would try to figure out a template
to render.
We cannot use a 204 No Content response. Htmx would not swap anything when
we do that, while we want the html of the todo item to be removed from the
page.
② Have HTMX send out an itemDeleted event in the browser, so we can update the number of
active items.
③ Return an empty string (see point 1).
src/main/resources/templates/fragments.html
with this:
src/main/resources/templates/fragments.html
<button class="destroy"
hx:delete="@{/{id}(id=${item.id})}"
hx:target="|#list-item-${item.id}|"
hx-trigger="click"
hx-swap="outerHTML"
></button>
This is very similar to what we did for toggling the item completion state. The only difference is that
we now use hx:delete and a slightly different URL.
And again, we need to update the hx-trigger to ensure the number of active items remain in sync:
...
hx-trigger="itemAdded from:body, itemCompletionToggled from:body,
itemDeleted from:body"
Start the application again and enjoy the absence of page refreshes as you add items, toggle their
completion status and remove them.
5.4. Summary
This chapter has shown how to implement the TodoMVC application using Spring Boot, Thymeleaf
and htmx. It showed the following htmx features:
• Trigger a request from any HTML element using hx-post, hx-get or hx-delete.
• Have htmx emit a client-side event using the HX-Trigger response header.
While this works fine, it is slightly inefficient as we do multiple requests to the backend to get into a
consistent state. In some cases, this might be what we want because parts of the UI should be
independent. Or because the UI part that listens to the event does an expensive call to the backend,
so it is better to have that asynchronously.
An alternative way of doing things is using out of band swaps. In that case, the server responds to the
browser with multiple top-level pieces of HTML. One piece will be used for the normal swapping, the
other piece (or pieces, can be multiple) will be used for the out of band swapping.
<div>
<!-- main HTML content here-->
</div>
<div id="some-id" hx-swap-oob="true">
<!-- some other content here -->
</div>
Htmx will use the main content to perform the swap of the HTML element that did the call to the
server. After that, it will use any additional HTML marked with hx-swap-oob="true" and swap that
with the HTML already on the page, given the id matches.
6.2. Example
We will build a small example to show how out of band swaps can be used. As having multiple top-
level HTML elements is not possible in standard Thymeleaf, we will use the Htmx-spring-boot library
again since it does support this.
It simulates a web application to enter the worked hours. On the left, there are the projects and on
the top, the current days of the week. When adding an amount in any of the input fields, two things
will happen:
• The day total at the bottom of the column where there was an update will be updated.
• The overall total in the top-right corner will be updated.
• Group: com.modernfrontendshtmx
• Artifact: oob-timesheets
com.modernfrontendshtmx.oobtimesheets.project.Project
package com.modernfrontendshtmx.oobtimesheets.project;
TimeRegistration represents the amount of hours worked (duration) on a certain date (date),
linked to a Project via the projectId.
com.modernfrontendshtmx.oobtimesheets.timeregistration.TimeRegistration
package com.modernfrontendshtmx.oobtimesheets.timeregistration;
import java.time.Duration;
import java.time.LocalDate;
import java.util.Objects;
return duration;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
TimeRegistration that = (TimeRegistration) o;
return projectId == that.projectId && Objects.equals(date, that
.date);
}
@Override
public int hashCode() {
return Objects.hash(projectId, date);
}
}
We also add a simple ProjectService so we can get a few projects to work with:
com.modernfrontendshtmx.oobtimesheets.project.ProjectService
package com.modernfrontendshtmx.oobtimesheets.project;
import org.springframework.stereotype.Service;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Service
public class ProjectService {
private final Map<Integer, Project> projects = new HashMap<>();
public ProjectService() {
projects.putAll(Stream.of(new Project(1, "CodeMorph"),
new Project(2, "IntelliBot"),
We also need a service to keep track of the time registrations. It has 2 methods:
• getTotal(): Calculates the total duration of the logged work given a set of project id’s and dates.
com.modernfrontendshtmx.oobtimesheets.timeregistration.TimeRegistrationService
package com.modernfrontendshtmx.oobtimesheets.timeregistration;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
@Service
public class TimeRegistrationService {
private final Map<ProjectDate, Duration> registrations = new
HashMap<>();
6.2.3. UI setup
We can now start building the UI. Add both services via dependency injection into the
HomeController that was generated by ttcli. Using the projectService, we can add the list of
projects as the projects model attribute to the model. We will also calculate the days of the current
week, starting from the first day of the week in the current locale (Monday usually in Europe, Sunday
usually in the USA).
com.modernfrontendshtmx.oobtimesheets.HomeController
package com.modernfrontendshtmx.oobtimesheets;
import com.modernfrontendshtmx.oobtimesheets.project.ProjectService;
import
com.modernfrontendshtmx.oobtimesheets.timeregistration.TimeRegistrationS
ervice;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.temporal.WeekFields;
import java.util.List;
import java.util.Locale;
import java.util.stream.Stream;
@Controller
@RequestMapping("/")
public class HomeController {
@GetMapping
public String index(Model model,
Locale locale) { ①
model.addAttribute("projects", projectService.getProjects()); ②
List<LocalDate> daysOfCurrentWeek = getDaysOfCurrentWeek(
locale);
model.addAttribute("days", daysOfCurrentWeek); ③
return "index";
}
① Inject Locale so we can use it to know what the first day of the week is for the user.
③ Add days to the model containing the 7 days of the current week.
index.html
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/main}"
xmlns:hx="http://www.w3.org/1999/xhtml">
<body>
<div layout:fragment="content" class="container mx-auto max-w-2xl my-4">
<div class="flex justify-between mb-4"> ①
<h1 class="text-3xl">Timesheets</h1>
<div>
<div class="text-sm text-gray-400 uppercase">Total</div>
<div id="overall-total"
class="text-2xl">0
</div>
</div>
</div>
<div>
<div class="grid grid-cols-9 mb-2 gap-x-2"> ②
<div class="font-bold col-span-2">Projects</div>
<div th:each="day : ${days}"> ③
<div class="flex justify-center">
<div th:text="${#temporals.format(day, 'dd
MMM')}"></div> ④
</div>
</div>
</div>
<div th:each="project : ${projects}" class="grid grid-cols-9 mb-
2 gap-x-2"> ⑤
<div th:text="${project.name()}" class="flex items-center
col-span-2"></div> ⑥
<div th:each="day : ${days}"> ⑦
<div>
<input type="text" class="w-full" name="value"> ⑧
</div>
</div>
</div>
</div>
</div>
</body>
</html>
① This <div> contains the title on the left and the total duration of logged work on the right. The
total duration is currently not functional yet, but we will get to that soon.
② Define a 9-column grid. We need 7 columns for the 7 days + 1 column for the project names. We
use 1 extra here. This allows use to give the project name column a span of 2 columns, so there is
a little more room for those names.
③ Loop over the days to generate a label for each day.
④ Format the LocalDate object using the built-in #temporals.format() function from Thymeleaf.
⑤ Loop over the projects to generate a row of input fields for each project.
⑥ Add the name of the project using col-span-2 so this uses the first 2 columns of the grid.
Start the application using the local profile for the Spring Boot application and npm run build &&
npm run watch to have the live reload running for further editing.
Figure 13. Application showing 3 projects and the dates of the current week
This is how the <input> in index.html looks like with this change:
index.html
hx:put="@{/projects/{projectId}/{date}(projectId=${project.id},date=${da
y})}"
hx-trigger="keyup changed delay:500ms">
Let’s also update the <div> showing the total so we can send an actual total from the controller as
well:
index.html
<div>
<div class="text-sm text-gray-400 uppercase">Total</div>
<div id="overall-total"
class="text-2xl"
th:text="${total}">0 ①
</div>
</div>
To make that work, we need to update the HomeController to have a @PutMapping method, and
we need to add the total attribute to the model.
com.modernfrontendshtmx.oobtimesheets.HomeController
@GetMapping
public String index(Model model,
Locale locale) {
model.addAttribute("projects", projectService.getProjects());
List<LocalDate> daysOfCurrentWeek = getDaysOfCurrentWeek(locale);
model.addAttribute("days", daysOfCurrentWeek);
model.addAttribute("total", getTotal(daysOfCurrentWeek));
return "index";
}
Set.copyOf(daysOfCurrentWeek));
}
com.modernfrontendshtmx.oobtimesheets.HomeController
@HxRequest ①
@PutMapping("/projects/{projectId}/{date}") ②
public String updateTimeRegistration(@PathVariable int projectId,
@PathVariable LocalDate date,
Double value, ③
Model model,
Locale locale) {
Duration duration = value == null ? Duration.ZERO : Duration
.ofMinutes((long) (value * 60.0)); ④
timeRegistrationService.addOrUpdateRegistration(projectId, date,
duration); ⑤
model.addAttribute("total", getTotal(getDaysOfCurrentWeek(locale)));
⑥
④ If the user does not enter any number, value might be null so we need to check for that. If the
value is not null, we convert it to a Duration.
If you test the application at this point, you can input a few values, but it might appear that not much
is happening. However, if you manually do a full page refresh, you do see the updated total. Let’s now
make the updates instant and not need a page refresh.
The browser dev tools shows that HTML like this is returned as response of the PUT request:
<div id="overall-total"
class="text-2xl">PT5H24M</div>
Remember that the default swap is innerHTML. This means that we ask the browser to make our
<div> the inner HTML of the <input> that triggered the request. This is not possible, so the browser
just ignores our swapping request.
What we really want to do is not swap anything where the input is, but update the <div> with the
overall-total id.
We just need to add hx-swap-oob="true" to our template to make this happen. For any HTML with
that attribute present, htmx will look it up on the page and swap out the content there, away from the
HTML element that triggered the request.
index.html
<div>
<div class="text-sm text-gray-400 uppercase">Total</div>
<div id="overall-total"
class="text-2xl"
th:text="${total}"
hx-swap-oob="true">0 ①
</div>
</div>
Test again, and the total should now properly update as soon as you enter something in any of the
input fields.
index.html
</div>
① This <div> is declared as a fragment named day-total with the following properties:
• hx-swap-oob="true": Needed for support for out-of-band swaps.
• th:id: To give each div a distinct id, we append dayTotal_ with the data it is about. For
example, for December 2nd, 2023, it would be dayTotal_20231202.
• th:text: We will add the actual total of the day to the model using a dynamically computed
name of the model element we want to refer to. With this double-underscores construct,
Thymeleaf first pre-processes the part between __${...}__ and then the th:text is evalated
to take the value from the model.
Next, update the HomeController to add those dayTotal_* values for the initial rendering of the
page:
com.modernfrontendshtmx.oobtimesheets.HomeController
@GetMapping
public String index(Model model,
Locale locale) {
model.addAttribute("projects", projectService.getProjects());
List<LocalDate> daysOfCurrentWeek = getDaysOfCurrentWeek(locale);
model.addAttribute("days", daysOfCurrentWeek);
model.addAttribute("total", getTotal(daysOfCurrentWeek));
for (LocalDate localDate : daysOfCurrentWeek) {
model.addAttribute("dayTotal_" + localDate.format
(DateTimeFormatter.ofPattern("yyyyMMdd")), ①
getTotal(List.of(localDate))); ②
}
return "index";
}
Next, we update the PUT request method to return the combination of the HTML for the total of the
day and the grand total. This is not possible by default in Thymeleaf, but luckily the htmx-spring-
boot library allows to do this using the io.github.wimdeblauwe.htmx.spring.boot.mvc class.
com.modernfrontendshtmx.oobtimesheets.HomeController
@HxRequest
@PutMapping("/projects/{projectId}/{date}")
public HtmxResponse updateTimeRegistration(@PathVariable int projectId,
@PathVariable LocalDate date,
Double value,
Model model,
Locale locale) {
Duration duration = value == null ? Duration.ZERO : Duration
model.addAttribute("total", getTotal(getDaysOfCurrentWeek(locale)));
model.addAttribute("day", date); ①
model.addAttribute("dayTotal_" + date.format(DateTimeFormatter
.ofPattern("yyyyMMdd")),
getTotal(List.of(date))); ②
return HtmxResponse.builder()
.view("index :: #overall-total")
.view("index :: day-total")
.build(); ③
}
① Add the day model attribute as the day-total fragment needs this. When the full page is
rendered, this is done by iterating over the days in the model. Here, we render the fragment in
isolation, so we need to provide it explictly.
② Calculate the day total and add it to the model.
③ Use HtmxResponse to generate a single response that renders 2 fragments: One that we
reference via the id, and one that we reference via the fragment name (set via th:fragment in
the HTML).
I’m mixing two approaches to referencing fragments here just to demonstrate
that both methods are viable. In a real production application, it’s advisable to
pick one approach and strive for consistency.
Restart the Spring Boot application and test it. Normally, both the day totals and the overall total
should be updating for each change.
6.3. Summary
This chapter has provided an in-depth exploration of out-of-band swaps, clarifying what they are and
explaining their application. The practical example showcased their real-world utility, illustrating how
these techniques can be effectively employed in concrete scenarios.
In this chapter, we will look at a few examples where adding some JavaScript can enhance the user
experience beyond what you can do with only htmx. We will start with some vanilla JavaScript, and
after that see how to combine htmx and AlpineJS.
The second fallacy of distributed computing is "latency is zero". We will also show how to deal with
that in our example by adding a loading indicator while htmx is busy doing a request to the server. We
don’t even need JavaScript for that part.
Since the network is not reliable, we need to put code in place inside the browser to deal with failed
requests. There are 3 possible ways of failure:
• The request was sent, but no response is received within an acceptable time frame.
• The request was sent, but the server sends back an error response (E.g. status codes in the 4xx or
5xx range).
• It was not even possible to send a request.
Let’s create a small example on how to handle those failures. We will create a webpage that shows an
interesting fact about a certain number. This will be backed by a remote call to
http://numbersapi.com/.
Run ttcli init to create a new project. Instead of Tailwind, let’s use Bootstrap this time.
• Group: com.modernfrontendshtmx
• Artifact: error-handling
pom.xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
Now we create an interface that matches the API signature. The methods in that interface represent
the API request that will be done when the method is called. We don’t need to implement the actual
logic ourselves, this will be done by the Spring Framework. This is similar to how Feign and Retrofit
work:
com.modernfrontendshtmx.errorhandling.NumbersApi
package com.modernfrontendshtmx.errorhandling;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.service.annotation.GetExchange;
② The number variable that is passed into the method, will be used as a path variable in the request.
To actually have an implementation of the NumbersApi interface that we can use, we need to create a
few beans in a configuration class:
com.modernfrontendshtmx.errorhandling.ErrorHandlingApplicationConfiguration
package com.modernfrontendshtmx.errorhandling;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.WebClient;
import
org.springframework.web.reactive.function.client.support.WebClientAdapte
r;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
@Configuration
public class ErrorHandlingApplicationConfiguration {
@Bean
public WebClient webClient() { ①
return WebClient.builder()
.baseUrl("http://numbersapi.com/")
.build();
}
@Bean
public HttpServiceProxyFactory httpServiceProxyFactory(WebClient
webClient) { ②
return HttpServiceProxyFactory
.builder(WebClientAdapter.forClient(webClient))
.build();
}
@Bean
public NumbersApi numbersApi(HttpServiceProxyFactory factory) { ③
return factory.createClient(NumbersApi.class);
}
}
① Create a WebClient to do the network requests. We hard-code the base URL in this example. In a
real application, you might want to externalize that configuration.
② Create an instance of HttpServiceProxyFactory using the WebClient bean. An instance of
HttpServiceProxyFactory is needed to be able to create an implementation of our
NumbersApi interface.
③ Have a bean that implements the NumbersApi interface that we can inject in our application code
to do API calls to the Numbers API.
Finally, we will create a NumbersApiGateway component which uses the NumbersApi for the actual
requests. It also will simulate some failures randomly, so we can test the error handling we want to
implement. This is the code:
com.modernfrontendshtmx.errorhandling.NumbersApiGateway
package com.modernfrontendshtmx.errorhandling;
import org.springframework.stereotype.Component;
import java.util.random.RandomGenerator;
import java.util.random.RandomGeneratorFactory;
@Component
public class NumbersApiGateway {
private static final RandomGenerator RANDOM_GENERATOR =
RandomGeneratorFactory.getDefault().create();
③ If the number is 1, simulate that calling the service takes a long time (But in the end, the response
does arrive in this case).
④ For any other number, simulate that calling the service gives an error.
7.1.3. Web UI
Our UI is quite simple, just an input to enter the number, a button to submit and a div to put the
response in:
src/main/resources/templates/index.html
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/main}"
xmlns:hx="http://www.w3.org/1999/xhtml">
<body>
① A form that does a GET request to /number-facts. The response HTML is swapped into the
innerHTML of the #result div.
package com.modernfrontendshtmx.errorhandling;
import io.github.wimdeblauwe.htmx.spring.boot.mvc.HxRequest;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/")
public class HomeController {
private final NumbersApiGateway numbersApiGateway;
@GetMapping
public String index(Model model) {
return "index";
@HxRequest
@GetMapping("/number-facts")
public String getRandomNumberFact(Model model,
Integer number) { ②
model.addAttribute("fact", numbersApiGateway.getFact(number));
③
return "fragments :: result"; ④
}
}
② Add Integer number as a parameter so that the number input field value will be injected into the
controller method.
③ Call the gateway to get information about the number and add the result as fact attribute in the
Model.
To properly render the response we get from the API, we create a small result fragment in
fragments.html:
src/main/resources/templates/fragments.html
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org">
<body>
<div th:fragment="result">
<div class="alert alert-primary" role="alert">
<div th:text="${fact}"></div>
</div>
</div>
</body>
</html>
We can test it now by running the Spring Boot application with the local profile and running npm
run build && npm run watch.
You will notice that sometimes it works fine, sometimes it takes a very long time to get an answer, and
sometimes it does not work at all. The lack of feedback makes for a very jarring user experience at
this point. Let’s improve this by adding proper error handling.
src/main/resources/templates/index.html
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/main}"
xmlns:hx="http://www.w3.org/1999/xhtml">
<body>
<div layout:fragment="content" class="container">
<h1 class="mt-2">Error Handling Demo</h1>
<form hx:get="@{/number-facts}"
hx-target="#result"
class="d-flex align-items-center">
<label class="me-4">
Enter a number to get a fact about that number:
<input type="number" name="number">
</label>
<button type="submit" class="btn btn-primary me-2">Get
fact!</button>
<div class="htmx-indicator spinner-border" role="status"> ①
<span class="visually-hidden">Loading...</span>
</div>
</form>
<div id="result" class="mt-4"></div>
</div>
</body>
</html>
① Add the div with the spinner-border class to have a visual indication that a request is ongoing.
Note how we added the htmx-indicator CSS class as well. Due to this, htmx will hide that spinner
when there is no request ongoing, and show it when there is. Because the div is inside the form,
htmx knows what request it must consider.
If you test again, you should see the spinner while the request is ongoing. It should be hidden again as
soon as the request is done (be it succesful or with a failure).
If it is not possible, or you don’t want to make the progress indicator part of your
triggering element, then you can use the hx-indicator attribute to indicate a CSS
reference to the element on the page that needs to act as the indicator. See hx-
indicator on the htmx website for more information.
Update index.html to listen for htmx events and show an error message if there is an error-related
event:
src/main/resources/templates/index.html
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/main}"
xmlns:hx="http://www.w3.org/1999/xhtml">
<body>
<div layout:fragment="content" class="container">
<h1 class="mt-2">Error Handling Demo</h1>
<form hx:get="@{/number-facts}"
hx-target="#result"
class="d-flex align-items-center">
<label class="me-4">
Enter a number to get a fact about that number:
<input type="number" name="number">
</label>
<button type="submit" class="btn btn-primary me-2">Get
fact!</button>
<div class="htmx-indicator spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</form>
<div id="result" class="mt-4"></div>
<div id="error-parent"></div> ①
function removeErrorMessage() { ⑦
const errorParent = document.getElementById('error-parent');
errorParent.innerHTML = '';
}
function showErrorMessage(templateId) { ⑧
const clonedTemplate = document.getElementById(templateId
).content.cloneNode(true);
const errorParent = document.getElementById('error-parent');
errorParent.innerHTML = '';
errorParent.appendChild(clonedTemplate);
document.getElementById('result').innerHTML = '';
}
</script>
</th:block>
</body>
</html>
① Add a div that will serve as the parent for the error message.
⑤ Use htmx:responseError event to show an error message if an error response returns from the
server.
⑥ The htmx:timeout event happens when htmx needs to wait longer than the configured timeout
for a response. In that case, we show an error message about the timeout being exceeded.
⑦ Helper method to clear out the error-parent div.
⑧ Helper method to show an error message and clear the result div as well.
• Per request by using hx-request on the element sending the request. See hx-
request for details.
Update layout/main.html to configure the timeout quite strict, so we can rather quickly test the
behavior:
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="htmx-config" content='{"timeout": 1000}'> ①
<title></title>
<link rel="stylesheet" th:href="@{/css/application.css}">
<link rel="stylesheet"
th:href="@{/webjars/bootstrap/css/bootstrap.min.css}">
</head>
For the error messages themselves, we will use the <template> tag so the HTML we want for the
message is already on the page, but it is not used yet. The showErrorMessage() JavaScript function
that we wrote will take that template content and put it inside the error-parent so it is shown to the
user.
src/main/resources/templates/fragments.html
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org">
<body>
<div th:fragment="result">
<div class="alert alert-primary" role="alert">
<div th:text="${fact}"></div>
</div>
</div>
<template th:fragment="error-message" id="htmx-request-error">
<div class="alert alert-danger d-flex align-items-center"
role="alert">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
fill="currentColor"
class="bi bi-exclamation-triangle" viewBox="0 0 16 16">
<path d="M7.938 2.016A.13.13 0 0 1 8.002 2a.13.13 0 0 1
.063.016.146.146 0 0 1 .054.057l6.857
11.667c.036.06.035.124.002.183a.163.163 0 0 1-.054.06.116.116 0 0 1-
.066.017H1.146a.115.115 0 0 1-.066-.017.163.163 0 0 1-.054-.06.176.176 0
0 1 .002-.183L7.884 2.073a.147.147 0 0 1 .054-.057zm1.044-.45a1.13 1.13
0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0
1.438-.99.98-1.767L8.982 1.566z"/>
<path d="M7.002 12a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1
5.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995z"/>
</svg>
<div class="ms-2">
There was an error during the update.
</div>
</div>
</template>
<template th:fragment="timeout-message" id="htmx-timeout">
<div class="alert alert-danger d-flex align-items-center"
role="alert">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
fill="currentColor"
class="bi bi-exclamation-triangle" viewBox="0 0 16 16">
<path d="M7.938 2.016A.13.13 0 0 1 8.002 2a.13.13 0 0 1
.063.016.146.146 0 0 1 .054.057l6.857
11.667c.036.06.035.124.002.183a.163.163 0 0 1-.054.06.116.116 0 0 1-
.066.017H1.146a.115.115 0 0 1-.066-.017.163.163 0 0 1-.054-.06.176.176 0
0 1 .002-.183L7.884 2.073a.147.147 0 0 1 .054-.057zm1.044-.45a1.13 1.13
0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0
1.438-.99.98-1.767L8.982 1.566z"/>
<path d="M7.002 12a1 1 0 1 1 2 0 1 1 0 0 1-2 0zM7.1
5.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995z"/>
</svg>
<div class="ms-2">
There was a timeout waiting on the request.
</div>
</div>
</template>
</body>
</html>
Inline SVG
The template uses inline SVG to allow styling the SVG’s using CSS. An SVG image can
only be styled when it is inlined, not when it is referenced.
My book Taming Thymeleaf shows how to add those SVG’s in fragments so make the
code easier to read. As it needs a bit of extra setup, I am just inlining them here, but
for a production application, I would strongly recommend to add them into
fragments.
With all this in place, we can test again. There should be proper feedback now for any scenario: a
progress indicator showing that a request is busy, a proper error message if something goes wrong or
if there is a timeout.
Figure 18. Error message when the request takes too long
7.2. AlpineJS
Using vanilla JavaScript is fine, but sometimes you might want to use a library to make things a bit
easier. My favorite lightweight library for client-side interactivity is Alpine.
Alpine is a rugged, minimal tool for composing behavior directly in your markup. Think of it like
jQuery for the modern web. Plop in a script tag and get going.
<span x-text="count"></span> ③
</div>
① x-data is an Alpine attribute that declares an Alpine scope and initialize the count variable to 0.
The variable count is available in all the HTML elements that are children on the <div> where the
x-data is declared.
② x-on: defines what should happen if a click event happens on the element that it is declared on.
In this example, the count variable is incremented when the button click event happens.
③ x-text is an Alpine attribute that binds the value of the variable to show it on the page. Here, it
shows the value of the count variable as text in the <span>.
The nice thing is that Alpine is reactive. If you increment the count when the button is clicked, the text
on the <span> is automatically updated.
We will create an application that demonstrates the integration of htmx and Alpine. It will showcase
an issue tracker where you can edit the issue description directly on the page and re-order the
subtasks associated with the issue.
• Group: com.modernfrontendshtmx
• Artifact: inline-editing
• Issue
• IssuePriority
• IssueType
• Status
• SubTask
package com.modernfrontendshtmx.inlineediting.issue;
import java.util.List;
package com.modernfrontendshtmx.inlineediting.issue;
package com.modernfrontendshtmx.inlineediting.issue;
TASK
}
package com.modernfrontendshtmx.inlineediting.issue;
package com.modernfrontendshtmx.inlineediting.issue;
package com.modernfrontendshtmx.inlineediting.issue.repository;
import com.modernfrontendshtmx.inlineediting.issue.*;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Component
public class IssueRepository {
private final Map<String, Issue> issues = new HashMap<>();
public IssueRepository() {
issues.putAll(Stream.of(new Issue("XXX-123",
"As a web developer, I want to
use htmx",
IssueType.STORY,
IssuePriority.MEDIUM,
"1.0",
List.of(
new SubTask("XXX-124",
"Read website htmx.org", Status.IN_PROGRESS),
new SubTask("XXX-125",
"Subscribe on htmx discord", Status.TODO),
new SubTask("XXX-126",
"Learn about various hx-trigger options", Status.TODO)
)))
.collect(Collectors.toMap(Issue::getKey,
Function.identity())));
}
Instead of using a single IssueService class, we will use different use case classes this time. There is
really no right or wrong in using a single service class versus using different use case classes. I just
wanted to show in this example that this coding style is also possible if you like it.
package com.modernfrontendshtmx.inlineediting.issue.usecase;
import com.modernfrontendshtmx.inlineediting.issue.Issue;
import
com.modernfrontendshtmx.inlineediting.issue.repository.IssueRepository;
import org.springframework.stereotype.Component;
@Component
public class GetIssueUseCase {
private final IssueRepository repository;
}
}
package com.modernfrontendshtmx.inlineediting.issue.web;
import com.modernfrontendshtmx.inlineediting.issue.Issue;
import
com.modernfrontendshtmx.inlineediting.issue.usecase.GetIssueUseCase;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/issues")
public class IssueController {
private final GetIssueUseCase getIssueUseCase;
@GetMapping("/{key}")
public String showIssue(@PathVariable("key") String key,
Model model) {
Issue issue = getIssueUseCase.execute(key);
model.addAttribute("issue", issue); ②
return "issue"; ③
}
}
② Add the Issue object in the model so we can use it to populate the values of the template.
The issue.html template calls a few fragments to display the different properties of an issue:
src/main/resources/templates/issue.html
<!DOCTYPE html>
<html lang="en"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
xmlns:th="http://www.thymeleaf.org"
layout:decorate="~{layout/main}">
<body>
<div layout:fragment="content" class="container mx-auto mt-4 max-w-2xl">
<div class="flex gap-2 mb-4">
<div class="flex justify-center items-center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0
24 24" stroke-width="1.5"
stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 12.75c1.148 0 2.278.08 3.383.237 1.037.146
1.866.966 1.866 2.013 0 3.728-2.35 6.75-5.25 6.75S6.75 18.728 6.75 15c0-
1.046.83-1.867 1.866-2.013A24.204 24.204 0 0112 12.75zm0 0c2.883 0
5.647.508 8.207 1.44a23.91 23.91 0 01-1.152 6.06M12 12.75c-2.883 0-
5.647.508-8.208 1.44.125 2.104.52 4.136 1.153 6.06M12 12.75a2.25 2.25 0
002.248-2.354M12 12.75a2.25 2.25 0 01-2.248-2.354M12 8.25c.995 0 1.971-
.08 2.922-.236.403-.066.74-.358.795-.762a3.778 3.778 0 00-.399-2.25M12
8.25c-.995 0-1.97-.08-2.922-.236-.402-.066-.74-.358-.795-.762a3.734
3.734 0 01.4-2.253M12 8.25a2.25 2.25 0 00-2.248 2.146M12 8.25a2.25 2.25
0 012.248 2.146M8.683 5a6.032 6.032 0 01-1.155-1.002c.07-.63.27-
1.222.574-1.747m.581 2.749A3.75 3.75 0 0115.318 5m0 0c.427-.283.815-.62
1.155-.999a4.471 4.471 0 00-.575-1.752M4.921 6a24.048 24.048 0 00-.392
3.314c1.668.546 3.416.914 5.223 1.082M19.08 6c.205 1.08.337 2.187.392
3.314a23.882 23.882 0 01-5.223 1.082"/>
</svg>
</div>
<div class="w-full">
<div class="text-sm text-gray-400" th:text="${issue.key}">XXX-
123</div>
<div th:replace="~{fragments :: issue-summary-
view(${issue})}"></div>
</div>
</div>
<dl class="flex flex-col gap-y-1 max-w-xl text-sm leading-6">
<div class="grid grid-cols-3">
<dt class="font-medium text-gray-900">Type</dt>
<div th:replace="~{fragments :: issue-type(${issue.type})}"></div>
</div>
<div class="grid grid-cols-3">
<dt class="font-medium text-gray-900">Prority</dt>
<div th:replace="~{fragments :: issue-
priority(${issue.priority})}"></div>
</div>
<div class="grid grid-cols-3">
<dt class="font-medium text-gray-900">Fix version</dt>
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org">
<body>
<div th:fragment="issue-type(issueType)"> ①
<th:block th:switch="${issueType.name()}">
<div th:case="'STORY'" class="text-gray-700 flex gap-x-1 items-
center">
<svg xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round"
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03
0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-
.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049
1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-
2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-
2.185a48.208 48.208 0 011.927-.184"/>
</svg>
<div>Story</div>
</div>
</th:block>
</div>
<div th:fragment="issue-priority(issuePriority)"> ②
<th:block th:switch="${issuePriority.name()}">
<dd th:case="'MEDIUM'" class="text-gray-700 flex gap-x-1 items-
center">
<svg xmlns="http://www.w3.org/2000/svg"
fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round"
d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75
9"/>
</svg>
<div>Medium</div>
</dd>
</th:block>
</div>
<div th:fragment="issue-summary-view(issue)"
class="flex items-center gap-x-1"> ③
<div class="text-xl" th:text="${issue.summary}">As a web developer,
I want to use htmx</div>
</div>
</body>
</html>
① The issue-type fragment renders the name of the issue type with a matching icon. It is currently
only implemented for the STORY type, but can be expanded by adding more th:case blocks if
needed.
② The issue-priority fragment is similar, but for visualizing the IssuePriority enum.
If you run the Spring Boot application (Use the local profile) and run npm run build, you should
see this UI in your browser when navigating to http://localhost:8080/issues/XXX-123:
The user can then click on the icon to start the inline editing. The <div> that shows the summary
should be swapped with an input field at that time. Using the input field, the user can edit the
summary by changing the value in the field. When the user is done and wants to confirm the change,
they can press Enter . To cancel the change, Esc can be used.
Let’s start by using Tailwind’s group modifier to implement showing a pencil icon when hovering over
the summary. Update the issue-summary-view fragment in fragments.html to this:
src/main/resources/templates/fragments.html
<div th:fragment="issue-summary-view(issue)"
class="flex items-center gap-x-1 group cursor-pointer"> ①
<div class="text-xl" th:text="${issue.summary}">As a web developer,
I want to use htmx</div>
<div class="hidden group-hover:block"> ②
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0
① Declare the complete <div> as a group and use the pointer cursor when hovering over it.
② Add a new <div> containing an SVG of a pencil that is hidden by default. If there is something that
is part of the group that is hovered, make the SVG visible by setting the display CSS property to
block.
Try this out. The pencil icon should be shown when you hover over the summary, or the bug icon.
Now that we have a proper visual indication, we need to make it functional. We will use htmx to do a
GET request for the input form field, and swap the viewing of the issue summary with the HTML to
edit the issue summary:
src/main/resources/templates/fragments.html
<div th:fragment="issue-summary-view(issue)"
class="flex items-center gap-x-1 group cursor-pointer"
hx:get="@{/issues/{key}/summary/inline-edit-
form(key=${issue.key})}"
hx-swap="outerHTML"> ①
<div class="text-xl" th:text="${issue.summary}">As a web developer,
I want to use htmx</div>
<div class="hidden group-hover:block">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0
24 24" stroke-width="1.5" stroke="currentColor"
class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652
2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0
011.13-1.897L16.863 4.487zm0 0L19.5 7.125"/>
</svg>
</div>
</div>
• hx:get to retrieve the HTML that is the form to edit the summary.
• hx:swap to indicate that we want to swap the currently displayed div with the response from the
server.
We need 2 more things to make this work: an endpoint in our controller and a new fragment with the
input field. Let’s start with the controller. Add a new method summaryInlineEditForm like this:
com.modernfrontendshtmx.inlineediting.issue.web.IssueController
@HxRequest ①
@GetMapping("/{key}/summary/inline-edit-form")
public String summaryInlineEditForm(@PathVariable("key") String key,
Model model) {
Issue issue = getIssueUseCase.execute(key);
model.addAttribute("issue", issue);
SummaryUpdateFormData formData = new SummaryUpdateFormData(); ②
formData.setSummary(issue.getSummary()); ③
model.addAttribute("formData", formData); ④
return "fragments :: issue-summary-edit"; ⑤
}
We also add an inner class SummaryUpdateFormData to represent the summary that we will be
changing through the form:
com.modernfrontendshtmx.inlineediting.issue.web.IssueController
The reason we use a class instead of @RequestParam is to add the validation annotation @NotBlank.
For the @NotBlank annotation to actually work, we need to add the spring-boot-starter-
validation dependency in our pom.xml:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
Our new controller method renders the issue-summary-edit which we have to create in
fragments.html:
src/main/resources/templates/fragments.html
<div th:fragment="issue-summary-edit(issue)"
id="summary-edit-form-wrapper">
<form id="summary-edit-form"
th:object="${formData}">
<div class="flex rounded-md shadow-sm ring-1 ring-inset ring-
gray-300 focus-within:ring-2 focus-within:ring-inset focus-within:ring-
indigo-600 sm:max-w-md">
<input type="text"
th:field="*{summary}"
autofocus
class="block text-xl flex-1 border-0 bg-transparent
py-1.5 pl-1 text-gray-900 placeholder:text-gray-400 focus:ring-0">
</div>
<p th:if="${#fields.hasErrors('summary')}"
th:text="${#strings.listJoin(#fields.errors('summary'), ',
')}"
class="mt-2 text-sm text-red-600" id="summary-error">summary
validation
error message(s).</p>
</form>
</div>
The form is standard Thymeleaf at this point. It uses th:object to bind the form data object to the
form and th:field to bind the summary property to the input.
If you test the application now, you can hover over the summary and click. Htmx will get the form
from the server and swap it in place so the user can start to edit the summary.
The next step is to allow the user to save the updates done in the input field. This means:
src/main/resources/templates/fragments.html
<div th:fragment="issue-summary-edit(issue)"
id="summary-edit-form-wrapper">
<form id="summary-edit-form"
th:object="${formData}"
@submit.prevent=""
hx-trigger="keyup[key=='Enter']"
hx:put="@{/issues/{key}/summary(key=${issue.key})}"
hx-swap="outerHTML"
hx-target="#summary-edit-form-wrapper"> ①
<div class="flex rounded-md shadow-sm ring-1 ring-inset ring-
gray-300 focus-within:ring-2 focus-within:ring-inset focus-within:ring-
indigo-600 sm:max-w-md">
<input type="text"
th:field="*{summary}"
autofocus
</form>
</div>
• Set the hx-target to the whole <div> of this fragment so the response fully replaces the
editing <div>. We could also have used hx-target="closest div" instead of using the id
we preferred.
We can now implement handling the PUT request in the IssueController. Before we do that, we
need to create a use case class that does the actual business logic:
com.modernfrontendshtmx.inlineediting.issue.usecase.UpdateSummaryUseCase
package com.modernfrontendshtmx.inlineediting.issue.usecase;
import com.modernfrontendshtmx.inlineediting.issue.Issue;
import
com.modernfrontendshtmx.inlineediting.issue.repository.IssueRepository;
import org.springframework.stereotype.Component;
@Component
public class UpdateSummaryUseCase {
private final IssueRepository repository;
String summary) {
Issue issue = repository.getIssue(key); ①
issue.setSummary(summary); ②
repository.saveIssue(issue); ③
return issue;
}
}
① Retrieve the current issue that is stored under the given key.
Now inject UpdateSummaryUseCase in the constructor of IssueController and add the following
method:
com.modernfrontendshtmx.inlineediting.issue.web.IssueController
@HxRequest
@PutMapping("/{key}/summary")
public String summaryUpdate(@PathVariable("key") String key,
@Valid @ModelAttribute("formData")
SummaryUpdateFormData formData, ①
BindingResult bindingResult, ②
Model model) {
if (bindingResult.hasErrors()) { ③
Issue issue = getIssueUseCase.execute(key); ④
model.addAttribute("issue", issue);
return "fragments :: issue-summary-edit"; ⑤
}
① Have Spring inject the SummaryFormData object that will have the summary from the input field in
the HTML.
② The @Valid on the form data, combined with this BindingResult will allow checking for errors
and render the form again with the errors displayed.
③ Check if there are validation errors.
④ Retrieve the current issue to be able to fill in the issue model attribute that the issue-summary-
edit fragment needs. The formData model attribute remains available automatically, we don’t
need to re-add it to the model.
⑤ Render the fragment that displays the form with input again to show the validation error and allow
the user to correctly enter the data.
⑥ If there are no validation errors, execute the UpdateSummaryUseCase using the key from the
path variable and the summary from the form data.
⑦ Render the issue-summary-view fragment which htmx will swap in place of the input form field.
If the user tries to make the summary empty, a validation message is shown:
The validation message here is the default that Spring provides. In a production
application, you will want to customize this further. See the Forms chapter in the
Taming Thymeleaf book for details on how to do that.
The user can now save a new summary, but there is no way to cancel. What a cancel should do is
swap the input field back to the read-only HTML that we started with when we initially rendered the
page. We can’t configure htmx to conditionally trigger an hx-get or hx-post for different hx-
trigger definitions. Luckily, we can combine Alpine with the htmx JavaScript API to achieve our goal.
We can do the exact same thing htmx does when we use hx-get from JavaScript like this:
<div
hx-get="/some-url"
hx-target="#my-target"
hx-swap="outerHTML">
...
</div>
Alpine allows handling keyboard events via the @keyup annotation. For example:
<div x-data=""
@keyup.enter="alert('Enter was pressed')">
</div>
src/main/resources/templates/fragments.html
<div th:fragment="issue-summary-edit(issue)"
id="summary-edit-form-wrapper"
th:x-data="'{cancelUrl: \'' +
@{/issues/{key}/summary(key=${issue.key})} + '\'}'"> ①
<form id="summary-edit-form"
th:object="${formData}"
@submit.prevent=""
hx-trigger="keyup[key=='Enter']"
hx:put="@{/issues/{key}/summary(key=${issue.key})}"
hx-swap="outerHTML"
hx-target="#summary-edit-form-wrapper"
@keyup.escape="htmx.ajax('GET', cancelUrl, {target: '#summary-
edit-form-wrapper', swap: 'outerHTML'})"> ②
<div class="flex rounded-md shadow-sm ring-1 ring-inset ring-
gray-300 focus-within:ring-2 focus-within:ring-inset focus-within:ring-
indigo-600 sm:max-w-md">
<input type="text"
th:field="*{summary}"
autofocus
class="block text-xl flex-1 border-0 bg-transparent
py-1.5 pl-1 text-gray-900 placeholder:text-gray-400 focus:ring-0">
</div>
<p th:if="${#fields.hasErrors('summary')}"
th:text="${#strings.listJoin(#fields.errors('summary'), ',
')}"
class="mt-2 text-sm text-red-600" id="summary-error">summary
validation
error message(s).</p>
</form>
</div>
① Declare an Alpine scope with the cancelUrl variable. We have Thymeleaf render the x-data
attribute, so we can properly build the URL we need.
② Use @keyup.escape to have Alpine run the JavaScript we have as value for the attribute when Esc
is pressed.
The URL we reference is a new URL that we have to serve from our IssueController to return only
the fragment that displays the summary of the issue:
com.modernfrontendshtmx.inlineediting.issue.web.IssueController
@HxRequest
@GetMapping("/{key}/summary")
public String summaryView(@PathVariable("key") String key,
Model model) {
Issue issue = getIssueUseCase.execute(key);
model.addAttribute("issue", issue);
return "fragments :: issue-summary-view";
}
Our inline editing user experience is now fully functional. However, things can go wrong, and we
should handle those cases in any production system. We could do something similar to what we have
done in the Vanilla JavaScript chapter, but since we are using Alpine already here, we can leverage
that.
• htmx:sendError
• htmx:responseError
• htmx:timeout
Using the @ syntax, we can add attributes that reference those events and run some Alpine code to
display an error message.
src/main/resources/templates/fragments.html
<div th:fragment="issue-summary-edit(issue)"
id="summary-edit-form-wrapper"
th:x-data="'{connectionFailure: false, cancelUrl: \'' +
@{/issues/{key}/summary(key=${issue.key})} + '\'}'"> ①
<form id="summary-edit-form"
th:object="${formData}"
@submit.prevent=""
hx-trigger="keyup[key=='Enter']"
hx:put="@{/issues/{key}/summary(key=${issue.key})}"
hx-swap="outerHTML"
hx-target="#summary-edit-form-wrapper"
@keyup.escape="htmx.ajax('GET', cancelUrl, {target: '#summary-
edit-form-wrapper', swap: 'outerHTML'})"
@htmx:timeout="connectionFailure = true"
@htmx:response-error="connectionFailure = true"
@htmx:send-error="connectionFailure = true"> ②
<div class="flex rounded-md shadow-sm ring-1 ring-inset ring-
gray-300 focus-within:ring-2 focus-within:ring-inset focus-within:ring-
indigo-600 sm:max-w-md">
<input type="text"
th:field="*{summary}"
autofocus
class="block text-xl flex-1 border-0 bg-transparent
py-1.5 pl-1 text-gray-900 placeholder:text-gray-400 focus:ring-0">
</div>
<div x-show="connectionFailure === true" class="text-red-500">
③
There was a problem updating the summary.
</div>
<p th:if="${#fields.hasErrors('summary')}"
th:text="${#strings.listJoin(#fields.errors('summary'), ',
')}"
class="mt-2 text-sm text-red-600" id="summary-error">summary
validation
error message(s).</p>
</form>
</div>
① Declare a variable connectionFailure in the Alpine scope and set the initial value to false.
② We have 3 extra attributes on this line that each will set connectionFailure to true if the event
is emitted by htmx.
③ Error message that is shown automatically when connectionFailure becomes true.
Note how we use the kebab-case version of the event (camelCase inside HTML
attributes is not supported). Htmx sends out both camelCase and kebab-case
versions of each event so things work out of the box with kebab-case.
If you need to integrate with something that only sends out camelCase versions of
events, you can have Alpine still work by appending .camel to the attribute:
@some-custom-event.camel="doSomething()"
Be sure to configure a timeout if you want to test the timeout error by adding this inside
layout/main.html:
<head>
...
<meta name="htmx-config" content='{"timeout": 1000}'>
...
</head>
If you now add a Thread.sleep(5000) in the UpdateSummaryUseCase and try to update the
summary, an error message is shown to indicate there was a problem. Alternatively, you can use the
browser development tools to simulate a network that is slow, or is offline to trigger the error
message.
Figure 25. Error message when the browser could not properly communicate with the server
We’ll start with showing the subtasks on the page. Update issue.html with some extra HTML:
src/main/resources/templates/issue.html
<div class="mt-4">
<div class="font-medium text-gray-900">Sub-tasks</div>
<div id="subtasks"
class="flex flex-col gap-y-1 mt-1 ml-4 divide-y border-t
border-b">
<div th:each="subTask : ${issue.subTasks}" class="grid grid-cols-
6"> ①
<div class="flex items-center gap-x-1">
<div class="sortable-handle cursor-pointer group"> ②
<svg xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4 group-hover:hidden">
<path stroke-linecap="round" stroke-linejoin="round"
d="M14.25 6.087c0-.355.186-.676.401-.959.221-
.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25
1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 01-
.657.643 48.39 48.39 0 01-4.163-.3c.186 1.613.293 3.25.315
4.907a.656.656 0 01-.658.663v0c-.355 0-.676-.186-.959-.401a1.647 1.647 0
00-1.003-.349c-1.036 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0
.713-.128 1.003-.349.283-.215.604-.401.959-.401v0c.31 0
.555.26.532.57a48.039 48.039 0 01-.642 5.056c1.518.19 3.058.309
4.616.354a.64.64 0 00.657-.643v0c0-.355-.186-.676-.401-.959a1.647 1.647
0 01-.349-1.003c0-1.035 1.008-1.875 2.25-1.875 1.243 0 2.25.84 2.25
1.875 0 .369-.128.713-.349 1.003-.215.283-.4.604-.4.959v0c0
.333.277.599.61.58a48.1 48.1 0 005.427-.63 48.05 48.05 0 00.582-
4.717.532.532 0 00-.533-.57v0c-.355 0-.676.186-.959.401-.29.221-
.634.349-1.003.349-1.035 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-
2.25c.37 0 .713.128 1.003.349.283.215.604.401.96.401v0a.656.656 0
00.658-.663 48.422 48.422 0 00-.37-5.36c-1.886.342-3.81.574-
5.766.689a.578.578 0 01-.61-.58v0z"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" fill="none"
viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4 hidden group-hover:block">
<path stroke-linecap="round" stroke-linejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/>
</svg>
</div>
<div class="text-gray-400" th:text="${subTask.key}">XXX-
124</div>
</div>
<div class="col-span-3" th:text="${subTask.summary}">Read
website htmx.org</div>
<div></div>
<div th:text="#{'Status.' + ${subTask.status}}">In
Progress</div> ③
</div>
</div>
src/main/resources/application.properties
spring.messages.basename=i18n/messages
src/main/resources/i18n/messages.properties
Status.TODO=To Do
Status.IN_PROGRESS=In Progress
Status.DONE=Done
pom.xml
<dependency>
<groupId>org.webjars.npm</groupId>
<artifactId>sortablejs</artifactId>
<version>1.15.0</version>
</dependency>
src/main/resources/templates/layout/main.html
<script type="text/javascript"
th:src="@{/webjars/alpinejs/dist/cdn.min.js}"></script>
<script type="text/javascript"
th:src="@{/webjars/htmx.org/dist/htmx.min.js}"></script>
<script type="text/javascript"
th:src="@{/webjars/sortablejs/Sortable.min.js}"></script> ①
<th:block layout:fragment="script-content">
</th:block>
Add the JavaScript code to initialize Sortable on our #subtasks div (Add this just before the closing of
the <body> tag):
src/main/resources/templates/issue.html
<th:block layout:fragment="script-content">
<script>
let subtasks = document.getElementById('subtasks');
new Sortable(subtasks, {
animation: 150,
ghostClass: 'bg-blue-300',
handle: '.sortable-handle'
});
</script>
</th:block>
You can hover over the icon in front of each sub-task and drag-and-drop a sub-task to another
position.
Superficially, it seems to work. But if you refresh the page, the order is lost again. We need a way to
send the updated order to the server after each drop, so the state is really saved on the backend.
SortableJS emits an end event when a dragging has occurred. We can trigger htmx on that event to
send the new order to the controller and save the state on the backend.
We will start by adding a hidden input that indicates the index of each subtask as it is displayed
initially on the screen:
src/main/resources/templates/issue.html
<div class="mt-4">
<div class="font-medium text-gray-900">Sub-tasks</div>
<form id="subtasks"
class="flex flex-col gap-y-1 mt-1 ml-4 divide-y border-t
border-b"
hx:put="@{/issues/{key}/subtasks(key=${key})}"
hx-trigger="end"> ①
<div th:fragment="subtask-items" th:each="subTask,iter :
${issue.subTasks}" class="grid grid-cols-6"> ②
<input type="hidden" name="subTaskOrder"
th:value="${iter.index}"> ③
<div class="flex items-center gap-x-1">
② Add iter to know the iteration index of the th:each. We also added th:fragment="subtask-
items" so we will be able to just render the iteration later on.
③ Add the hidden input with the index (0-based) of the current iteration.
With this in place, each sub-task row will have a hidden input. If we only consider those hidden inputs,
the order in the HTML is like this:
If we now drag the first subtask below the second subtask, htmx will do a request with these form
values:
subTaskOrder=1&subTaskOrder=0&subTaskOrder=2
So the subtask that was on index 1 (second in the list) should now be on index 0 (first in the list). The
subtask that was on index 0 (first in the list) should be on index 1 (second in the list). The subtask on
index 2 (3rd in the list) remains in place.
This isn’t working yet because we haven’t written the controller method for it. So let’s fix that now.
com.modernfrontendshtmx.inlineediting.issue.web.IssueController
@HxRequest
@PutMapping("/{key}/subtasks")
public String reorderSubtasks(@PathVariable("key") String key,
int[] subTaskOrder, ①
Model model) {
Issue issue = reorderSubtasksUseCase.execute(
key,
subTaskOrder); ②
model.addAttribute("issue", issue);
return "issue :: subtask-items"; ③
}
① We need to get the int array that htmx sends to be able to update the order. The parameter name
needs to match with the name of the hidden input.
③ After the subtasks are re-ordered, we need to update the hidden inputs to reflect that new order.
The easiest way is to render the subtasks table again and send that as a response that htmx can
swap in. We refer to the th:fragment="subtask-items" that we added in issue.html.
All that is left is write the ReorderSubtasksUseCase (and inject it into the IssueController):
com.modernfrontendshtmx.inlineediting.issue.usecase.ReorderSubtasksUseCase
package com.modernfrontendshtmx.inlineediting.issue.usecase;
import com.modernfrontendshtmx.inlineediting.issue.Issue;
import com.modernfrontendshtmx.inlineediting.issue.SubTask;
import
com.modernfrontendshtmx.inlineediting.issue.repository.IssueRepository;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
@Component
public class ReorderSubtasksUseCase {
private final IssueRepository repository;
The drag and drop keeps working, even after htmx swaps in new HTML because the
SortableJS is initialized on the <form> with the subtasks id. That element remains
in the DOM and only the children are swapped.
If we had used an outerHTML swap which also replaced the form, then we would
need to re-initialize SortableJS.
This is easily done via htmx.onLoad() which is called whenever new HTML is loaded
into the DOM:
<script>
htmx.onLoad(function (content) {
let subtasks = document.getElementById('subtasks');
new Sortable(subtasks, {
animation: 150,
ghostClass: 'bg-blue-300',
handle: '.sortable-handle'
});
});
</script>
We now have fully functional drag and drop which saves the updated order of the sub-tasks on the
backend as soon as the user drops the sub-task.
7.3. Summary
This chapter has shown how to combine vanilla JavaScript with htmx to optimize the user experience.
We also looked at the Alpine library as a way to simplify things and have even more functionality while
writing less JavaScript.
Chapter 8. Security
In many cases, web applications require security measures to protect them. We will explore how this
aspect impacts the use of htmx by creating a simple example that incorporates Spring Security.
The only part where we do have to do something special is on POST, PUT and DELETE requests if
CSRF-protection is enabled. When using a form with Thymeleaf, this part is handled automatically.
With htmx, we will need to pass the CSRF-token manually when doing those types of requests.
Let’s create a small URL bookmarking application to see how to handle this in practice.
8.2. Bookmarks
• Group: com.modernfrontendshtmx
• Artifact: bookmarks
Update the pom.xml to add Spring Security by adding the starter for security:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
We can now configure Spring Security by adding the following @Configuration class:
com.modernfrontendshtmx.bookmarks.WebSecurityConfiguration
package com.modernfrontendshtmx.bookmarks;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import
org.springframework.security.config.annotation.web.builders.HttpSecurity
;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import
org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import
org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class WebSecurityConfiguration {
@Bean
public PasswordEncoder encoder() {
return PasswordEncoderFactories.
createDelegatingPasswordEncoder();
}
@Bean
public UserDetailsService userDetailsService(PasswordEncoder
encoder) { ①
UserDetails userDetails = User.builder()
.username("admin") ②
.password(encoder.encode("admin"))
③
.build();
InMemoryUserDetailsManager inMemoryUserDetailsManager = new
InMemoryUserDetailsManager();
inMemoryUserDetailsManager.createUser(userDetails);
return inMemoryUserDetailsManager;
}
@Bean
① Declare a UserDetailsService bean with a single in-memory user for demonstration purposes.
④ Configure the security of the endpoints of the application via a SecurityFilterChain bean.
⑦ Use the default form login. If you would like to add a custom login page, you would need to
configure that here. For this example, we’ll just use the default login page provided by Spring
Security.
com.modernfrontendshtmx.bookmarks.Bookmark
package com.modernfrontendshtmx.bookmarks;
We can now update the HomeController. To keep things simple, the controller keeps track of the
actual data as well. In a real application, you would obviously split this off to service and/or a
repository.
com.modernfrontendshtmx.bookmarks.HomeController
package com.modernfrontendshtmx.bookmarks;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/")
public class HomeController {
private final AtomicInteger autoIncrementCounter = new
AtomicInteger(); ①
private final Map<Integer, Bookmark> bookmarkMap = new HashMap<>();
②
@GetMapping
public String index(Model model) {
model.addAttribute("bookmarks", bookmarkMap.values()); ③
return "index";
}
@PostMapping("/bookmarks/create") ④
public String createBookmark(@ModelAttribute("formData")
CreateBookmarkFormData formData) { ⑤
addBookmark(formData);
return "redirect:/"; ⑥
}
③ Put all the bookmarks in the Model so the home page can display them.
We also need CreateBookmarkFormData to represent the form data when the user submits the
form:
com.modernfrontendshtmx.bookmarks.CreateBookmarkFormData
package com.modernfrontendshtmx.bookmarks;
Now we can update index.html with the UI that shows a form and a submit button. Below the form,
the list of currently saved bookmarks is shown.
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/main}">
<body>
</html>
① HTML <form> where we set the action endpoint to /bookmarks/create and the method to POST.
② The list of the current bookmarks, looping over each bookmark in the bookmarks attribute that we
added to the model in the controller.
After adding a few entries, the application should look like this:
By default, Spring Security enables CSRF protection for endpoints that modify state on the server.
Normally, those are POST, PUT or PATCH requests. Any such requests need to send along a CSRF
token, otherwise the server will not accept the request.
How come this works while we have done nothing ourselves to send the CSRF token?
If you use the "View source" feature of your browser, you will notice that Thymeleaf has inserted a
hidden input into your form:
</form>
The hidden input has the CSRF token, and that token is sent along with the request to the server
automatically.
Because we used th:method="post", Thymeleaf automatically includes a hidden input to fulfill the
CSRF protection requirement. If the hidden input were not there, we would get a 403 Forbidden trying
to add the bookmark.
Notice how we currently get a page reload for each form submit. Let’s use htmx to avoid the page
reload.
• hx-swap since the result of our POST will be an HTML snippet representing a single bookmark.
We want to add this HTML at the end of the current list of bookmarks.
• hx-target to have htmx swap the new html into the bookmarks-list div.
We also need an additional method in our HomeController to support htmx calling the /bookmarks
endpoint:
com.modernfrontendshtmx.bookmarks.HomeController
@HxRequest
@PostMapping("/bookmarks")
public String htmxCreateBookmark(@ModelAttribute("formData")
CreateBookmarkFormData formData,
Model model) {
Bookmark bookmark = addBookmark(formData); ①
model.addAttribute("bookmark", bookmark); ②
return "index :: bookmark"; ③
}
① Use the same addBookmark(formData) method like in the other endpoint to actually store the
bookmark.
② Add the bookmark to the Model since the fragment we want to render needs this.
③ Tell Spring to render the bookmark fragment from index.html. This works because we had set
the th:fragment="bookmark" attribute in index.html.
If we test again now, we can still save the bookmarks and there is no page refresh going on.
If we look at the form tag again, we see that we have 2 attributes we don’t need for htmx:
<form id="bookmark-creation-form"
th:object="${formData}"
th:action="@{/bookmarks/create}"
th:method="post"
hx:post="@{/bookmarks}"
hx-swap="beforeend"
hx-target="#bookmarks-list">
The th:action and the th:method can be removed and it should still work:
<form id="bookmark-creation-form"
th:object="${formData}"
hx:post="@{/bookmarks}"
hx-swap="beforeend"
hx-target="#bookmarks-list">
However, if we do that, things don’t work. We get a 403 Forbidden on the call that htmx does because
the hidden input with the CSRF token is no longer present now.
We have 2 options:
• Leave the th:action and th:method in place and benefit from the automatic CSRF hidden input
that Thymeleaf provides.
• Manually add the hidden input.
<form id="bookmark-creation-form"
th:object="${formData}"
hx:post="@{/bookmarks}"
hx-swap="beforeend"
hx-target="#bookmarks-list">
<input type="hidden"
th:name="${_csrf.parameterName}"
th:value="${_csrf.token}"/> ①
...
</form>
① Add the hidden input using the _csrf variable that is available in any Thymeleaf template.
By adding the hidden input, we can leave out the th:method and th:action and things work again.
src/main/resources/templates/index.html
hx:delete="@{/bookmarks/{id}(id=${bookmark.id()})}"
hx-target="closest .bookmark"
hx-swap="outerHTML">Delete</button> ①
</div>
</th:block>
</div>
• hx-target set to closest .bookmark. So the swap target is the HTML element with the
bookmark class that is closest to the button. This is in fact the parent <div> that shows the
information of the bookmark.
• hx-swap set to outerHTML as we will replace the complete div with an empty string.
com.modernfrontendshtmx.bookmarks.HomeController
@HxRequest
@DeleteMapping("/bookmarks/{id}")
@ResponseBody ①
public String deleteBookmark(@PathVariable int id) {
bookmarkMap.remove(id); ②
return ""; ③
}
① Add @ResponseBody to indicate to Spring that we want to return the literal HTML, not a reference
to a Thymeleaf template.
② Remove the bookmark from the in-memory map.
③ Return an empty String.
If we try this, we get a 403 Forbidden again in the Developer Tools since there is no CSRF token passed
along with our request.
We don’t have a form now, so we can’t use what we used for the bookmark creation here. We could,
of course, wrap the button in a form if we wanted (and it would have the additional benefit of still
working if JavaScript is disabled). But suppose we don’t want to add the <form> element, what are our
options?
src/main/resources/templates/index.html
hx:delete="@{/bookmarks/{id}(id=${bookmark.id()})}"
hx-target="closest .bookmark"
hx-swap="outerHTML"
hx-include=".delete-csrf-token">Delete</button>
①
<input class="delete-csrf-token" type="hidden"
th:name="${_csrf.parameterName}"
th:value="${_csrf.token}"/> ②
</div>
</th:block>
</div>
② Add the hidden input, similar to what we did for saving the bookmark.
Due to the hx-include, htmx includes the value of the hidden input in the DELETE request. The
server accepts the request since it now has a valid CSRF token.
A second alternative, is adding <meta> tags to the head of the page with the CSRF token value. We can
then use JavaScript to read those and set them on the request that htmx does:
src/main/resources/templates/index.html
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/main}">
<head> ①
<meta name="_csrf" th:content="${_csrf.token}"/>
<meta name="_csrf_header" th:content="${_csrf.headerName}"/>
</head>
...
hx:delete="@{/bookmarks/{id}(id=${bookmark.id()})}"
hx-target="closest .bookmark"
hx-swap="outerHTML">Delete</button>
</div>
</th:block>
</div>
...
<th:block layout:fragment="script-content">
<script>
var token = document.querySelector('meta[name="_csrf"]').
content; ②
var headerName = document.querySelector(
'meta[name="_csrf_header"]').content;
document.addEventListener('htmx:configRequest', (evt) => { ③
evt.detail.headers[headerName] = token;
});
</script>
</th:block>
① Add the meta tags in the <head> section. NOTE: the head section will be merged with the one from
the layout/main.html.
② Read the value of the token and header name to use in the AJAX request for CSRF.
③ When htmx configures the request, we add the extra header, so it is sent along.
src/main/resources/templates/index.html
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/main}">
...
hx:delete="@{/bookmarks/{id}(id=${bookmark.id()})}"
hx-target="closest .bookmark"
hx-swap="outerHTML">Delete</button>
</div>
</th:block>
</div>
...
<th:block layout:fragment="script-content">
<script th:inline="javascript"> ①
const token = /*[[${_csrf.token}]]*/ 'sample-csrf-token'; ②
const headerName = /*[[${_csrf.headerName}]]*/ 'X-Sample-CSRF-
Header';
document.addEventListener('htmx:configRequest', (evt) => { ③
evt.detail.headers[headerName] = token;
});
</script>
</th:block>
② Use the /*[[${...}]]*/ expression to have Thymleaf inject the CSRF token as a JavaScript
variable.
③ Add a listener for the htmx:configRequest event to add the CSRF related header to the request.
This concludes the section implementing the delete endpoint that is secured with CSRF protection.
Which option out of the 3 you choose is really a personal preference, as they all work equally well.
When you use htmx, it might be the case that the user does an interaction that leads to a partial HTML
swap. If the user was logged out, the login page is shown in place of where the normal result of the
htmx request is swapped.
We can simulate this scenario with our current application like this:
If you save the bookmark, the browser will show something like this:
To fix this, htmx allows the server to send HX-Refresh as a header in a response when the user
session is expired, and it will force a full page refresh.
This is quite easily done with the helper class that the htmx-spring-boot library provides called
HxRefreshHeaderAuthenticationEntryPoint.
com.modernfrontendshtmx.bookmarks.WebSecurityConfiguration
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
var entryPoint = new HxRefreshHeaderAuthenticationEntryPoint(); ①
var requestMatcher = new RequestHeaderRequestMatcher("HX-Request");
②
return http
.authorizeHttpRequests(registry -> registry
.requestMatchers(HttpMethod.GET, "/css/**"
).permitAll()
.requestMatchers(HttpMethod.GET, "/webjars/**"
).permitAll()
.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.exceptionHandling(exception ->
exception.defaultAuthenticationEntryPointFor
(entryPoint,
requestMatcher)) ③
.build();
}
③ Use the entryPoint and requestMatcher as the default authentication entry point for the
exception handling.
What happens in practice with this configuration is that the htmx request from the browser will get
back a 403 Forbidden with the HX-Refresh response header set. When htmx sees this response, it will
not try to swap in the response, but force a full page refresh in the browser. This in turn will activate
the normal Spring Boot behavior of showing the proper login page to the user.
Try the same test sequence as before and notice how we now get the wanted behavior.
8.7. Summary
This chapter showed what implications there are if Spring Security is enabled on your htmx project
and how to handle this properly.
The book is available for free online reading, and there is also an option to purchase a hard copy or an
e-book version to support the authors' work.
• Group: com.modernfrontendshtmx
• Artifact: contacts-app
package com.modernfrontendshtmx.contactsapp.contact;
this.email = email;
}
package com.modernfrontendshtmx.contactsapp.contact;
The contacts are saved and retrieved through a repository abstraction called ContactRepository:
package com.modernfrontendshtmx.contactsapp.contact.repository;
import com.modernfrontendshtmx.contactsapp.contact.Contact;
import java.util.List;
List<Contact> findAll();
}
To get started, we can only retrieve contacts. We will be adding more methods to this repository soon.
package com.modernfrontendshtmx.contactsapp.contact.repository;
import com.modernfrontendshtmx.contactsapp.contact.Contact;
import com.modernfrontendshtmx.contactsapp.contact.ContactId;
import org.springframework.stereotype.Repository;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Repository
public class InMemoryContactRepository implements ContactRepository {
public InMemoryContactRepository() {
values.putAll(Stream.of(new Contact(new ContactId(1L),
"Wim",
"Deblauwe",
"555-789-999",
"wim@example.com"),
new Contact(new ContactId(2L),
"John",
"Doe",
"555-123-456",
"john@example.com"),
new Contact(new ContactId(3L),
"Ada",
"Lovelace",
"555-873-321",
"ada@lovelace.com"))
.collect(Collectors.toMap(Contact::getId, Function
.identity())));
}
@Override
public List<Contact> findAll() {
return List.copyOf(values.values());
}
}
Create a ContactService to contain the "business logic". In this application, this will be a very thin
layer as it is a CRUD application.
package com.modernfrontendshtmx.contactsapp.contact.service;
import com.modernfrontendshtmx.contactsapp.contact.Contact;
import
com.modernfrontendshtmx.contactsapp.contact.repository.ContactRepository
;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class ContactService {
private final ContactRepository repository;
We also need a controller to show the HTML for the list of contacts, so let’s create one now:
package com.modernfrontendshtmx.contactsapp.contact.web;
import com.modernfrontendshtmx.contactsapp.contact.Contact;
import
com.modernfrontendshtmx.contactsapp.contact.repository.ContactRepository
;
import
com.modernfrontendshtmx.contactsapp.contact.service.ContactService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import java.util.List;
@Controller
@RequestMapping("/contacts") ①
public class ContactController {
private final ContactService service;
@GetMapping
public String viewContacts(Model model) {
List<Contact> contactList = service.getAll(); ③
model.addAttribute("contacts", contactList); ④
return "contacts/list"; ⑤
}
}
As the final change to the Java code, we will have the home page redirect to the /contacts endpoint:
package com.modernfrontendshtmx.contactsapp;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/")
public class HomeController {
@GetMapping
public String index(Model model) {
return "redirect:/contacts"; ①
}
}
We will now implement the Thymeleaf template to show the list of contacts. Start by updating
layout/main.html with some extra Tailwind CSS styling:
<!DOCTYPE html>
<html th:lang="|${#locale.language}-${#locale.country}|"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title></title>
<link rel="stylesheet" th:href="@{/css/application.css}">
</head>
<body>
<div class="container mx-auto max-w-2xl mt-4"> ①
<main layout:fragment="content">
</main>
</div>
<script type="text/javascript"
th:src="@{/webjars/alpinejs/dist/cdn.min.js}"></script>
<script type="text/javascript"
th:src="@{/webjars/htmx.org/dist/htmx.min.js}"></script>
<th:block layout:fragment="script-content">
</th:block>
</body>
</html>
① Wrap the <main> element with a <div> with some Tailwind classes to center the content and give
it a maximum width for bigger screens.
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/main}">
<body>
<div layout:fragment="content">
<div>
<div class="sm:flex sm:items-center mb-4">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold leading-6 text-gray-
900">Contacts</h1>
<p class="mt-2 text-sm text-gray-700">A list of all your
contacts.</p>
</div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<button type="button"
class="block rounded-md bg-indigo-600 px-3 py-2
text-center text-sm font-semibold text-white shadow-sm hover:bg-indigo-
500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-
offset-2 focus-visible:outline-indigo-600">
Add user
</button>
</div>
</div>
<div class="flex gap-x-2 items-center">
<label for="search" class="text-sm font-medium leading-6
text-gray-900">Search</label>
<input type="text" name="search" id="search"
class="rounded-md border-0 py-1.5 text-gray-900
shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400
focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm
sm:leading-6"
>
<button type="submit"
class="text-indigo-400 hover:text-indigo-900">Search
</button>
</div>
<div class="mt-8 flow-root">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle
sm:px-6 lg:px-8">
<table class="min-w-full divide-y divide-gray-300">
<thead>
<tr>
<th scope="col" class="table-header-first-
column">Name</th>
<th scope="col" class="table-header">
Phone</th>
<th scope="col" class="table-header">
Email</th>
<th scope="col" class="relative py-3.5 pl-3
pr-4 sm:pr-0">
<span class="sr-only">Edit or
View</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tr th:each="contact : ${contacts}"> ①
<td class="table-value-first-column"
th:text="|${contact.givenName}
${contact.familyName}|"></td>
<td class="table-value"
th:text="${contact.phone}"></td>
<td class="table-value"
th:text="${contact.email}"></td>
<td class="relative whitespace-nowrap py-4
th:href="@{/contacts/{id}/edit(id=${contact.id.value()})}">Edit</a>
<a class="text-indigo-400 hover:text-
indigo-900"
th:href="@{/contacts/{id}(id=${contact.id.value()})}">View</a>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
① Iterate over each contact and show the properties of that contact.
We are using a few CSS classes in this template that we need to define in our application.css:
@tailwind base;
@tailwind components;
@tailwind utilities;
.table-header-first-column {
@apply py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-
900 sm:pl-0;
}
.table-header {
@apply px-3 py-3.5 text-left text-sm font-semibold text-gray-900;
}
.table-value-first-column {
@apply whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-
gray-900 sm:pl-0;
}
.table-value {
With all this in place, we can run our Spring Boot application using the local profile and start the live
reload server with npm run build && npm run start.
We need a way to generate a new primary key for the contact and to save it. Update the
ContactRepository like this:
package com.modernfrontendshtmx.contactsapp.contact.repository;
import com.modernfrontendshtmx.contactsapp.contact.Contact;
import com.modernfrontendshtmx.contactsapp.contact.ContactId;
import java.util.List;
List<Contact> findAll();
package com.modernfrontendshtmx.contactsapp.contact.repository;
import com.modernfrontendshtmx.contactsapp.contact.Contact;
import com.modernfrontendshtmx.contactsapp.contact.ContactId;
import org.springframework.stereotype.Repository;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
@Repository
public class InMemoryContactRepository implements ContactRepository {
public InMemoryContactRepository() {
List.of(new Contact(nextId(),
"Wim",
"Deblauwe",
"555-789-999",
"wim@example.com"),
new Contact(nextId(),
"John",
"Doe",
"555-123-456",
"john@example.com"),
new Contact(nextId(),
"Ada",
"Lovelace",
"555-873-321",
"ada@lovelace.com"))
.forEach(this::save); ②
}
@Override
public ContactId nextId() {
return new ContactId(sequence.incrementAndGet()); ③
}
@Override
public List<Contact> findAll() {
return List.copyOf(values.values());
}
@Override
public void save(Contact contact) {
values.put(contact.getId(), contact); ④
}
}
① Use an AtomicLong to generate a new unique long value each time nextId() is called.
Now that the repository makes it possible to store contacts, we can offer a slightly more user-friendly
API from our ContactService to store a new contact:
package com.modernfrontendshtmx.contactsapp.contact.service;
import com.modernfrontendshtmx.contactsapp.contact.Contact;
import
com.modernfrontendshtmx.contactsapp.contact.repository.ContactRepository
;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class ContactService {
private final ContactRepository repository;
this.repository = repository;
}
① Method to store a new contact given the 4 properties we store for a contact.
At the web layer, we create a Java object that represents the HTML form:
package com.modernfrontendshtmx.contactsapp.contact.web;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
Because of the validation annotations, we need to add an extra dependency in our pom.xml:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
The ContactController itself will use the CreateContactFormData in the GET and the POST
mapping:
package com.modernfrontendshtmx.contactsapp.contact.web;
import com.modernfrontendshtmx.contactsapp.contact.Contact;
import
com.modernfrontendshtmx.contactsapp.contact.service.ContactService;
import jakarta.validation.Valid;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import java.util.List;
@Controller
@RequestMapping("/contacts")
public class ContactController {
private final ContactService service;
@GetMapping
public String viewContacts(Model model) {
List<Contact> contactList = service.getAll();
model.addAttribute("contacts", contactList);
return "contacts/list";
}
@GetMapping("/new")
public String newContact(Model model) {
model.addAttribute("formData", new CreateContactFormData());
return "contacts/edit";
}
@PostMapping("/new")
public String createNewContact(Model model,
@ModelAttribute("formData") @Valid
CreateContactFormData formData,
BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
return "contacts/edit";
}
service.storeNewContact(formData.getGivenName(),
formData.getFamilyName(),
formData.getPhone(),
formData.getEmail());
return "redirect:/contacts";
}
}
If this combination of GET and POST for form handling is unfamiliar, have a look at
my blog post that explains this in more detail: Form handling with Thymeleaf.
The HTML for saving a new contact is handled by the templates/contacts/edit.html Thymeleaf
template, which looks like this:
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/main}">
<body>
<div layout:fragment="content">
<div class="sm:flex sm:items-center mb-4">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold leading-6 text-gray-900"
>New contact</h1>
<p class="mt-2 text-sm text-gray-700">Add a new contact to
your list of contacts.</p>
</div>
</div>
<form th:action="@{/contacts/new}"
th:object="${formData}"
th:method="post"
class="flex flex-col gap-y-2">
<div th:replace="~{fragments/forms :: textinput('Given Name',
'givenName')}"></div>
<div th:replace="~{fragments/forms :: textinput('Family Name',
'familyName')}"></div>
<div th:replace="~{fragments/forms :: textinput('Phone',
'phone')}"></div>
<div th:replace="~{fragments/forms :: emailinput('Email',
'email')}"></div>
<button type="submit" class="button-primary mt-4">Save</button>
</form>
</div>
</body>
</html>
The code uses Thymeleaf fragments to keep everything nice and readable. Copy the fragments from
GitHub and place them in the forms.html template:
src/main/resources/templates/fragments/forms.html
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
lang="en">
<div th:fragment="textinput(labelText, fieldName)"
th:class="${cssClass}">
...
</div>
On the contact list page, we replace the <button> with an <a> link to navigate to the separate page
where a contact can be added:
The final change is adding the CSS class .button-primary to our application.css:
src/main/resources/static/css/application.css
.button-primary {
@apply block rounded-md bg-indigo-600 px-3 py-2 text-center text-sm
font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible
:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-
visible:outline-indigo-600
}
Start everything again, and there should be an 'Add contact' button now. Press it and the browser will
navigate to our new page which allows to add a contact:
If everything is filled in correctly, then the contact is saved and the list of contacts is shown again.
Including the new contact.
9.3. Search
We will now implement the search field that is present on the list of contacts.
Starting from the HTML template, add a form around the search input field. Change the code from
this:
</button>
</div>
to:
The ContactController needs to be updated to take into account that an optional request
parameter q might be added. If that is the case, we only show the users that have names that match
with the value of the query parameter:
com.modernfrontendshtmx.contactsapp.contact.web.ContactController
@GetMapping
public String viewContacts(Model model,
@RequestParam(value = "q", required = false)
String query) { ①
List<Contact> contactList;
if (query != null) { ②
model.addAttribute("query", query); ③
contactList = service.searchContacts(query); ④
} else {
contactList = service.getAll();
}
model.addAttribute("contacts", contactList);
return "contacts/list";
② If the query is present, search for the matching contacts. If it is not present, just return all
contacts.
③ Add the value of the request parameter as a model attribute. This ensures that the query
parameter in the URL and the value in search input will always match.
④ Ask the service for the list of matching contacts.
com.modernfrontendshtmx.contactsapp.contact.service.ContactService
com.modernfrontendshtmx.contactsapp.contact.repository.ContactRepository
com.modernfrontendshtmx.contactsapp.contact.repository.InMemoryContactRepository
@Override
public List<Contact> findAllWithNameContaining(String query) {
return values.values()
.stream()
.filter(contact -> contact.hasName(query)) ①
.toList();
}
com.modernfrontendshtmx.contactsapp.contact.Contact
With this code in place, we can search for contacts. Here, we searched for "Ada":
Figure 35. Application showing contact that matches with search value
Note how the URL and the input field value are always in sync. Due to using a query parameter, the
search is now easily shareable as well. You can just send the URL to another person using the same
application and the search will work just fine.
To view a single contact, we need a way to retrieve a single contact from the repository:
com.modernfrontendshtmx.contactsapp.contact.repository.ContactRepository
com.modernfrontendshtmx.contactsapp.contact.repository.InMemoryContactRepository
@Override
public Optional<Contact> findById(ContactId contactId) {
return Optional.ofNullable(values.get(contactId));
}
We relay the method through the ContactService so the controller can use it:
com.modernfrontendshtmx.contactsapp.contact.service.ContactService
If the contact cannot be found, the service method will throw a ContactNotFoundException:
package com.modernfrontendshtmx.contactsapp.contact;
In ContactController, add a method to show the contact information for the /contacts/<id>
URL:
com.modernfrontendshtmx.contactsapp.contact.web.ContactController
@GetMapping("/{id}")
public String viewContact(Model model,
@PathVariable("id") long id) {
Contact contact = service.getContact(new ContactId(id));
model.addAttribute("contact", contact);
return "contacts/view";
}
src/main/resources/templates/contacts/view.html
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/main}">
<body>
<div layout:fragment="content">
<h1 th:text="|${contact.givenName} ${contact.familyName}|"
class="text-base font-semibold leading-6 text-gray-900"></h1>
<div>
<div>Phone: <span th:text="${contact.phone}"></span></div>
<div>Email: <span th:text="${contact.email}"></span></div>
</div>
src/main/resources/static/css/application.css
.button-secondary {
@apply block rounded-md bg-white px-3 py-2 text-sm font-semibold
text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-
50;
}
We don’t need to change contacts/list.html as the view link is already properly implemented to
navigate to the view URL:
src/main/resources/templates/contacts/list.html
Try to view any of the contacts now. The application should look similar to this:
The UI for editing a contact is very similar to the one we already implemented for adding a new
contact. Because they are almost the same, we will parameterize edit.html to be used for adding
and editing.
For this purpose, we will add an enum EditMode so the Thymeleaf template knows which of the 2
modes of operation should be used:
package com.modernfrontendshtmx.contactsapp.contact.web;
enum EditMode {
CREATE,
UPDATE
}
src/main/resources/templates/contacts/edit.html
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/main}">
<body>
<div layout:fragment="content">
<div class="sm:flex sm:items-center mb-4">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold leading-6 text-gray-900">
①
<span th:if="${editMode.name() == 'CREATE'}">New
contact</span>
<span th:if="${editMode.name() == 'UPDATE'}">Update
contact</span>
</h1>
<p class="mt-2 text-sm text-gray-700"> ②
<span th:if="${editMode.name() == 'CREATE'}">Add a new
contact to your list of contacts.</span>
<span th:if="${editMode.name() == 'UPDATE'}">Update a
contact from your list of contacts.</span>
</p>
</div>
</div>
<form th:action="${editMode?.name() ==
'UPDATE'}?@{/contacts/{id}/edit(id=${formData.id})}:@{/contacts/new}"
th:object="${formData}"
th:method="post"
class="flex flex-col gap-y-2"> ③
<div th:replace="~{fragments/forms :: textinput('Given Name',
'givenName')}"></div>
<div th:replace="~{fragments/forms :: textinput('Family Name',
'familyName')}"></div>
<div th:replace="~{fragments/forms :: textinput('Phone',
'phone')}"></div>
<div th:replace="~{fragments/forms :: emailinput('Email',
'email')}"></div>
<button type="submit" class="button-primary mt-4">Save</button>
</form>
<div th:if="${editMode?.name() == 'UPDATE'}" class="flex mt-8 gap-
4"> ④
<form th:action="@{/contacts/{id}/delete(id=${formData.id})}"
th:method="post">
<p>
<a href="/contacts"
class="button-secondary">Back</a>
</p>
</div>
</div>
</body>
</html>
④ Show the delete and back buttons only for updates, not when adding new contacts.
To make the edit mode available to the template, we need to update our controller:
com.modernfrontendshtmx.contactsapp.contact.web.ContactController
@GetMapping("/new")
public String newContact(Model model) {
model.addAttribute("formData", new CreateContactFormData());
model.addAttribute("editMode", EditMode.CREATE); ①
return "contacts/edit";
}
@PostMapping("/new")
public String createNewContact(Model model,
@ModelAttribute("formData") @Valid
CreateContactFormData formData,
BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
model.addAttribute("editMode", EditMode.CREATE); ②
return "contacts/edit";
}
service.storeNewContact(formData.getGivenName(),
formData.getFamilyName(),
formData.getPhone(),
formData.getEmail());
return "redirect:/contacts";
...
@GetMapping("/{id}/edit")
public String editContact(Model model,
@PathVariable("id") long id) { ③
Contact contact = service.getContact(new ContactId(id));
model.addAttribute("formData", EditContactFormData.from(contact));
model.addAttribute("editMode", EditMode.UPDATE);
return "contacts/edit";
}
@PostMapping("/{id}/edit")
public String doEditContact(Model model,
@PathVariable("id") long id,
@ModelAttribute("formData") @Valid
EditContactFormData formData,
BindingResult bindingResult) { ④
if (bindingResult.hasErrors()) {
model.addAttribute("editMode", EditMode.UPDATE);
return "contacts/edit";
}
service.updateContact(new ContactId(id),
formData.getGivenName(),
formData.getFamilyName(),
formData.getPhone(),
formData.getEmail());
return "redirect:/contacts";
}
@PostMapping("/{id}/delete")
public String deleteContact(@PathVariable("id") long id) { ⑤
service.deleteContact(new ContactId(id));
return "redirect:/contacts";
}
② Also set the editMode to CREATE if there was a validation error and the page needs to be re-
rendered.
Note how we are using an @PostMapping to implement the delete, as forms in HTML only support
GET and POST. We will soon avoid this issue by leveraging htmx, but for now we’ll stick to standard
Spring Boot and Thymeleaf.
The controller needs the EditContactFormData class to represent the HTML form during the editing
of a contact:
package com.modernfrontendshtmx.contactsapp.contact.web;
import com.modernfrontendshtmx.contactsapp.contact.Contact;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
return formData;
}
com.modernfrontendshtmx.contactsapp.contact.service.ContactService
repository.save(contact);
}
com.modernfrontendshtmx.contactsapp.contact.repository.ContactRepository
com.modernfrontendshtmx.contactsapp.contact.repository.InMemoryContactRepository
@Override
public void deleteById(ContactId contactId) {
values.remove(contactId);
}
src/main/resources/static/css/application.css
.button-primary-danger {
@apply block rounded-md bg-red-600 px-3 py-2 text-center text-sm
font-semibold text-white shadow-sm hover:bg-red-500 focus-visible
:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-
visible:outline-red-600;
}
Remember how we needed to use a POST for the deletion of a contact because forms only support
that? When we use htmx, we can use the full range of verbs: GET, POST, PUT, PATCH and DELETE.
com.modernfrontendshtmx.contactsapp.contact.web.ContactController
@DeleteMapping("/{id}")
public RedirectView deleteContact(@PathVariable("id") long id,
RedirectAttributes redirectAttributes)
{ ①
service.deleteContact(new ContactId(id));
redirectAttributes.addFlashAttribute("successMessage",
"Deleted Contact!"); ②
return redirectView;
}
According to the Mozilla Developer Network (MDN) web docs on the 302 Found
response, the HTTP method of the request will be unchanged when the redirected
HTTP request is issued.
We set the status code to 303 See Other instead of the default 302 Found to avoid that
the DELETE request we do would transfer onto the redirect to /contacts.
We can now use htmx to trigger the DELETE request in the edit.html template:
src/main/resources/templates/contacts/edit.html
<p>
<a href="/contacts"
class="button-secondary">Back</a>
</p>
</div>
• hx-target: By default, htmx targets the element that issues the request. In our case, the
delete button. If we would use the default, then the response of the delete request (the
updated list of contacts) would be placed inside the delete button. This is obviously not what
we want. By setting the hx-target to body, the list of contacts will be swapped in the body,
which is what we want.
• hx-push-url: The url is not updated by default, but in this case, we show the list of contacts
again after the delete. So it makes sense to update the URL to match with the normal URL that
shows the list of contacts. As we redirect to /contacts as a result of the delete operation, we
can have htmx push that URL to the location bar of the browser.
As an additional confirmation to the user that the contact is deleted, we will show a flash message. To
support this, we add some extra code in our main.html layout template:
① Show a message if the successMessage attribute is set (normally via a flash attribute so it is
automatically removed when the user refreshes the page).
Figure 39. Flash message and updated URL without browser refresh after delete
Let’s see how we can use the power of htmx to implement this.
package com.modernfrontendshtmx.contactsapp.contact.web;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = NoDuplicateContactsByEmailValidator.class)
public @interface NoDuplicateContactsByEmail {
String message() default "There is already a contact with this email
address";
And this the validator itself which is called for any class where we place the
@NoDuplicateContactsByEmail annotation:
package com.modernfrontendshtmx.contactsapp.contact.web;
import
com.modernfrontendshtmx.contactsapp.contact.service.ContactService;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
public NoDuplicateContactsByEmailValidator(ContactService
contactService) {
this.contactService = contactService;
}
@Override
public boolean isValid(CreateContactFormData formData,
ConstraintValidatorContext context) {
if (contactService.contactWithEmailExists(formData.getEmail()))
{ ②
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate("There is
already a contact with this email address")
.addPropertyNode("email") ③
.addConstraintViolation();
return false;
}
return true;
}
}
com.modernfrontendshtmx.contactsapp.contact.service.ContactService
Repository updates:
com.modernfrontendshtmx.contactsapp.contact.repository.ContactRepository
com.modernfrontendshtmx.contactsapp.contact.repository.InMemoryContactRepository
@Override
public boolean existsByEmail(String email) {
return values.values()
.stream()
.anyMatch(contact -> contact.getEmail().equals(email));
}
All that is left is adding our new annotation to the form data class:
@NoDuplicateContactsByEmail
public class CreateContactFormData {
...
We can test this, and it should work fine, but only after form submit. We will now see how we can
make it validate as we type with just a few extra lines of code.
com.modernfrontendshtmx.contactsapp.contact.web.ContactController
@GetMapping("/new") ①
@HxRequest ②
public String validateNewContact(Model model,
@ModelAttribute("formData") @Valid
CreateContactFormData formData, ③
BindingResult bindingResult) { ④
model.addAttribute("formData", formData);
model.addAttribute("editMode", EditMode.CREATE);
return "contacts/edit"; ⑤
}
④ Add the BindingResult just after the form data to ensure the Thymeleaf template has all the info
it needs to render validation errors (Although we don’t actually use it here in the method body, you
need to have it declared).
⑤ Render the contacts/edit.html template in the response.
To call the endpoint from htmx, we will update the email input component. It has currently 2
parameters: The labelText and the fieldName. We will add an extra parameter to pass in a URL to
do the live validation.
leading-6 text-gray-900"
th:text="${labelText}">
Text input label
</label>
<div class="relative mt-2 rounded-md shadow-sm">
<input th:id="${fieldName}"
type="email"
th:field="*{__${fieldName}__}"
hx:trigger="${inlineValidationUrl != null?'keyup changed
delay:200ms':null}"
hx:get="${inlineValidationUrl?:null}"
hx:select="|#${fieldName}-form-element|"
hx:target="|#${fieldName}-form-element|"
hx-swap="outerHTML"
hx-include="closest form"
class="block w-full rounded-md border-0 py-1.5
focus:ring-2 focus:ring-inset sm:text-sm sm:leading-6 ring-1 ring-inset"
th:classappend="${#fields.hasErrors('__${fieldName}__')?'ring-red-300
focus:border-red-300 focus:ring-red-500':'ring-gray-300 focus:ring-gray-
500 focus:border-gray-500'}"
> ②
<div th:if="${#fields.hasErrors('__${fieldName}__')}"
class="pointer-events-none absolute inset-y-0 right-0 flex
items-center pr-3">
<svg class="h-5 w-5 text-red-500" viewBox="0 0 20 20"
fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75
0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2
1 1 0 000 2z"
clip-rule="evenodd"/>
</svg>
</div>
</div>
<p th:if="${#fields.hasErrors('__${fieldName}__')}"
th:text="${#strings.listJoin(#fields.errors('__${fieldName}__'),
', ')}"
class="mt-2 text-sm text-red-600" th:id="'__${fieldName}__'+ '-
error'">Field validation error message(s).</p>
</div>
① We added the inlineValidationUrl parameter to the fragment, and also set an id which we
will need for htmx.
② We added the following attributes to the <input> element:
• hx:trigger: We want to trigger a request to the server when the user entered something in
the email input, with a debounce policy of 200 milliseconds.
• hx:get: Indicate that we want to do a GET request using the passed in
inlineValidationUrl argument.
• hx:select: Take the email component DOM tree from the HTML response, so we only swap
that part of our page.
• hx:target: Swap the result of the hx-select into the HTML element of the current page with
this value.
• hx-swap: We want to swap the full DOM tree with the new DOM tree from the response, we
need to set this to outerHTML.
• hx-include: By default, htmx will only send the values of the input that the hx-trigger is
defined on. But in this case, we want to send the whole form since that is what our validation
endpoint requires. By using closest form, htmx will search the DOM tree for the closest
<form> tag that our <input> is included in, and send all input values from the whole form to
the server.
All that is left is setting the correct validation URL in edit.html where we use the email component
fragment:
src/main/resources/templates/contacts/edit.html
<form th:action="${editMode?.name() ==
'UPDATE'}?@{/contacts/{id}/edit(id=${formData.id})}:@{/contacts/new}"
th:object="${formData}"
th:method="post"
class="flex flex-col gap-y-2">
<div th:replace="~{fragments/forms :: textinput('Given Name',
'givenName')}"></div>
<div th:replace="~{fragments/forms :: textinput('Family Name',
'familyName')}"></div>
<div th:replace="~{fragments/forms :: textinput('Phone',
'phone')}"></div>
<div th:replace="~{fragments/forms :: emailinput('Email',
'email', '/contacts/new')}"></div> ①
<button type="submit" class="button-primary mt-4">Save</button>
</form>
If you try this, you should get a validation error when typing an email address that already exists.
To have a better user experience, we should only swap in the error message itself and leave the
<input>.
The fragment that renders the email input currently only renders the error message <p> element
when there is an error via th:if. We will now need to render this element always so we can target it
from htmx.
Change the th:if to use th:classappend to hide or show the element using the hidden CSS class:
src/main/resources/templates/fragments/forms.html
<p
th:classappend="${#fields.hasErrors('__${fieldName}__')?'':'hidden'}"
th:id="'__${fieldName}__'+ '-error'"
th:text="${#strings.listJoin(#fields.errors('__${fieldName}__'),
', ')}"
class="mt-2 text-sm text-red-600">Field validation error
message(s).</p>
hx:select="|#${fieldName}-form-element|"
hx:target="|#${fieldName}-form-element|"
with:
hx:select="|#${fieldName}-error|"
hx:target="|#${fieldName}-error|"
So only the error is taken from the response and swapped into the DOM.
With this change in place, you can type in the input without issues, and the error message will appear
and disappear as needed. There is still one more thing we need to fix for a full solution. The error icon
is currently not appearing when the error message appears. We can fix this with a bit of Alpine.js
scripting.
src/main/resources/templates/fragments/forms.html
We added 2 attributes:
• x-data: This defines an Alpine.js scope with the showErrorIcon variable initialized to true if
there are already errors while rendering, or false if there are no errors yet.
• x-on:htmx:after-settle: This is an Alpine.js event listener, listening for the
htmx:afterSettle event. When this event happens, we check the error <p> element to know if
it is hidden or not. If the CSS classes do not contain the hidden class, it means, we need to show
the error icon.
To make the error icon listen for the showErrorIcon variable changes, we use x-show like this:
src/main/resources/templates/fragments/forms.html
<div x-show="showErrorIcon"
th:id="'__${fieldName}__'+ '-error-icon'"
class="pointer-events-none absolute inset-y-0 right-0 flex
items-center pr-3">
<svg class="h-5 w-5 text-red-500" viewBox="0 0 20 20"
fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75
0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2
1 1 0 000 2z"
clip-rule="evenodd"/>
</svg>
</div>
• The email address is validated on the server while typing in the input field.
• The focus is preserved when the inline server validation happens.
• The server message is dynamically shown while typing and the error icon is shown when needed.
• The error message and error icon are correctly shown when actually submitting the form.
By adding the property hx-validate, htmx will check the form for validation errors before sending
the request. If there are errors, the request will not be sent to the server.
To test this, add hx:validate to the <input> tag of the emailinput fragment:
<input th:id="${fieldName}"
type="email"
th:field="*{__${fieldName}__}"
hx:trigger="${inlineValidationUrl != null?'keyup changed
delay:200ms':null}"
hx:get="${inlineValidationUrl?:null}"
hx:select="|#${fieldName}-error|"
hx:target="|#${fieldName}-error|"
hx:validate="${inlineValidationUrl != null}"
...
So hx:validate is set to true if there is an inlineValidationUrl so htmx will not send out a
request to the validation URL unnecessary. If there is no validation url, then hx-validate is set to
false in the rendered HTML and no client side validation is done first.
If you test with the Dev Tools open on the network tab, you will notice that no request is done to the
server until the input value starts to look like an email address.
9.8. Pagination
The next thing we want to add to our application is support for pagination. Currently, we load all the
contacts when showing the home page, which is not sustainable as more and more contacts are
added to the application.
pom.xml
<dependency>
<groupId>net.datafaker</groupId>
<artifactId>datafaker</artifactId>
<version>2.0.0</version>
</dependency>
public InMemoryContactRepository() {
Faker faker = new Faker();
for (int i = 0; i < 100; i++) {
Name name = faker.name();
String firstName = name.firstName();
String lastName = name.lastName();
save(new Contact(nextId(),
firstName,
lastName,
faker.phoneNumber().phoneNumber(),
faker.internet().emailAddress(firstName.toLowerCase
(Locale.ROOT) + "." + lastName.toLowerCase(Locale.ROOT))));
}
}
Add a new method to ContactRepository that takes in a page number and a size of page and
returns a Page of results:
com.modernfrontendshtmx.contactsapp.contact.repository.ContactRepository
The Page class is a helper class to represent a page of items and some metadata about the page:
com.modernfrontendshtmx.contactsapp.infrastructure.repository.Page
package com.modernfrontendshtmx.contactsapp.infrastructure.repository;
import java.util.List;
com.modernfrontendshtmx.contactsapp.contact.repository.InMemoryContactRepository
@Override
public Page<Contact> findAllOrderedByName(int page, int size) {
List<Contact> contacts = values.values().stream()
.sorted(Comparator.comparing(contact -> contact.
getGivenName() + " " + contact.getFamilyName()))
.skip((long) page * size)
.limit(size)
.toList();
return new Page<>(contacts,
page,
size,
values.size());
}
As we keep all contacts in memory in our application anyway, the pagination has
limited use currently, but when using an actual database, this can greatly boost
performance.
Also note that if you use an actual database, don’t get everything and paginate in
Java like we do here. Use the pagination functionalities of the database!
Add a method to the ContactService to get the paginated results. We will hardcode to a page size
of 10:
com.modernfrontendshtmx.contactsapp.contact.service.ContactService
The final step on the Java side is to update the controller with a page request parameter so the user
can select the appropriate page in the UI:
com.modernfrontendshtmx.contactsapp.contact.web.ContactController
@GetMapping
public String viewContacts(Model model,
@RequestParam(value = "q", required = false)
String query,
@RequestParam(value = "page", required =
false, defaultValue = "0") int page) { ①
List<Contact> contactList;
if (query != null) {
model.addAttribute("query", query);
contactList = service.searchContacts(query);
} else {
Page<Contact> contactsPage = service.getAll(page); ②
contactList = contactsPage.values();
model.addAttribute("page", contactsPage.number()); ③
model.addAttribute("size", contactsPage.size());
model.addAttribute("totalElements", contactsPage.
totalElements());
}
model.addAttribute("contacts", contactList);
return "contacts/list";
}
The application will now show the first page of 10 contacts. To allow the user to navigate to other
pages, add a 'Previous' and 'Next' link below the table of the contacts:
templates/contacts/list.html
...
</table>
<div class="flex justify-between mt-4">
<a th:href="${page > 0}?@{/contacts(page=${page
- 1})}:'#'"
class="button-secondary w-32 text-center"
th:classappend="${page ==
0?'disabled':null}">Previous</a>
<a th:href="${(page + 1) * size <
totalElements}?@{/contacts(page=${page + 1})}:'#'"
class="button-secondary w-32 text-center"
th:classappend="${(page + 1) * size >=
totalElements?'disabled':null}">Next</a>
</div>
The current page value is used to render the previous and next links. Suppose the current page is 5,
then these links will be rendered:
• Previous: /contacts?page=4
• Next: /contacts?page=6
We also introduce a new CSS class disabled which is configured in application.css like this:
src/main/resources/static/css/application.css
.disabled {
pointer-events: none;
}
.button-secondary.disabled {
@apply text-gray-300;
}
Figure 41. Pagination buttons allow the user to select a page of contacts
You can use the 'Previous' and 'Next' buttons to navigate to other pages.
templates/contacts/list.html
...
<tr th:if="${(page + 1) * size < totalElements}"> ①
<td colspan="4">
<div class="flex justify-center mt-4">
<button class="button-secondary w-32 text-center"
hx:get="@{/contacts(page=${page + 1})}"
hx-select="tbody > tr"
hx-target="closest tr"
hx-swap="outerHTML"> ②
Load more
</button>
</div>
</td>
</tr>
</tbody>
...
① Render the "Load more" button if there are still next pages.
② The button has the following htmx attributes:
• hx:get: Thymeleaf expression to build the URL for getting the next page of contacts.
• hx-select: From the response (which is the full page), only select the rows from the contacts
table body.
• hx-target: Swap the selected part of the response into the closest <tr> element compared
to the <button>. So this is the row where the button is currently displayed.
• hx-swap: Swap the full HTML with the response HTML. This will replace the row that shows the
"Load more" button with the rows from the response.
Screenshot of the application after using the "Load more" button a few times:
Figure 42. Load more button allows to load more contacts without refreshing the page
The request is the same, we just need to change the trigger that triggers the request from clicking a
button to revealing the last row of the table:
templates/contacts/list.html
...
<tr th:if="${(page + 1) * size < totalElements}">
<td colspan="4">
<div class="flex justify-center mt-4">
<span
hx:get="@{/contacts(page=${page + 1})}"
hx-trigger="revealed"
hx-select="tbody > tr"
hx-target="closest tr"
hx-swap="outerHTML"
> ①
</span>
</div>
</td>
</tr>
</tbody>
...
• The hx-trigger now has the revealed value (We discussed this in the Special events
section).
With this change, you can now keep scrolling through the list in the browser and new pages are
loaded dynamically as you scroll.
com.modernfrontendshtmx.contactsapp.contact.repository.ContactRepository
com.modernfrontendshtmx.contactsapp.contact.repository.InMemoryContactRepository
@Override
public Page<Contact> findAllWithNameContaining(String query,
int page,
int size) {
List<Contact> unpaged = values.values()
.stream()
.filter(contact -> contact.hasName(query))
.toList();
List<Contact> contacts = unpaged.stream()
.sorted(Comparator.comparing(contact -> contact.
getGivenName() + " " + contact.getFamilyName()))
.skip((long) page * size)
.limit(size)
.toList();
return new Page<>(contacts,
page,
size,
unpaged.size());
}
com.modernfrontendshtmx.contactsapp.contact.service.ContactService
com.modernfrontendshtmx.contactsapp.contact.web.ContactController
@GetMapping
return "contacts/list";
}
With this in place, we don’t have active search yet, but we do have pagination when searching with a
form submit. However, the pagination is not working properly. The first page of the results is correct,
but after that, all the contacts are shown, not just the matching ones. If you open the Developer Tools
of your browser and look at the network request, you will see why. The q search parameter is not
passed along when we request a next page.
We can easily fix this by updating the URL we use for the htmx request:
templates/contacts/list.html
...
<tr th:if="${(page + 1) * size < totalElements}">
<td colspan="4">
<div class="flex justify-center mt-4">
<span
hx:get="@{/contacts(page=${page + 1},q=${query})}"
hx-trigger="revealed"
hx-select="tbody > tr"
hx-target="closest tr"
hx-swap="outerHTML"
> ①
</span>
</div>
</td>
</tr>
</tbody>
...
We can now update the search <input> with some htmx attributes:
</div>
• hx-target and hx-swap: The swap should target the <tbody> element as we want to replace
the table contents that has the list of contacts.
• hx-push-url: By setting this to true, the URL of the browser is updated to match the request
we do with htmx. This will put the input value of the search in the URL so a full page reload
triggers the same search query.
• hx-indicator: So we get a visual indication if a query takes a bit of time.
On the Java side, we will determine whether the request is from htmx; if so, we will return only the
contacts table. We could have used hx-select as well, but we save some bandwidth by only sending
what is required from the server. It is also is a nice example of how you can use HtmxRequest.
com.modernfrontendshtmx.contactsapp.contact.web.ContactController
@GetMapping
public String viewContacts(Model model,
@RequestParam(value = "q", required = false)
String query,
@RequestParam(value = "page", required =
false, defaultValue = "0") int page,
HtmxRequest htmxRequest) { ①
Page<Contact> contactsPage;
if (query != null) {
model.addAttribute("query", query);
contactsPage = service.searchContacts(query, page);
} else {
contactsPage = service.getAll(page);
}
model.addAttribute("page", contactsPage.number());
model.addAttribute("size", contactsPage.size());
model.addAttribute("totalElements", contactsPage.totalElements());
model.addAttribute("contacts", contactsPage.values());
if(htmxRequest.isHtmxRequest()) { ②
return "contacts/list :: tbody"; ③
} else {
return "contacts/list";
}
}
① Inject HtmxRequest to be able to query if the current request is an htmx request or not.
If you try this now, you should have an immediate response of the contacts list while typing in the
search input.
Most browsers show a little x button to clear the search input. If you try to use it,
you’ll notice that it does not update the contact list. To make that work, we need to
use search as an additional trigger like this:
hx-trigger="search, keyup delay:200ms changed"
Let us start by adding a delete link for each row that we display. Update list.html:
templates/contacts/list.html
• hx-confirm to first ask for confirmation to the user before we send out the request to the
server.
• hx-swap is set to outerHTML so we can replace the deleted row with an empty string so the
row also visually disappears from the table. The swap:1s is needed for the nice animation that
we will explain in a bit.
• hx-target: the target of the swap is the table row that this link is contained in.
With this in place, we have 2 situations in which htmx issues a DELETE request:
We need to update ContactController to take this into account because we need different
behavior. For the new link, we just want to return an empty string so we replace the whole table row
with nothing. For the old button, we need to keep the redirect behavior we have so that the browser
shows the list of contacts after the delete is done.
To be able to make this distinction, we add an id of delete-button on the delete button in the
edit.html.
com.modernfrontendshtmx.contactsapp.contact.web.ContactController
@DeleteMapping("/{id}")
public HtmxResponse deleteContact(@PathVariable("id") long id,
RedirectAttributes redirectAttributes,
HtmxRequest htmxRequest) { ①
service.deleteContact(new ContactId(id));
if ("delete-button".equals(htmxRequest.getTriggerId())) { ②
redirectAttributes.addFlashAttribute("successMessage",
"Deleted Contact!");
② Check the id of the element that triggered the htmx request via the getTriggerId() method on
HtmxRequest. If it is the delete-button, keep the old behavior.
③ Wrap the RedirectView in an HtmxResponse so we can use HtmxResponse as a return type for
both cases.
④ Return an empty response in the case of the delete link.
tr.htmx-swapping > td {
animation: fade-out 1s ease-out, shrink 500ms 500ms;
}
@keyframes fade-out {
0% {
opacity: 1;
}
50%{
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes shrink {
to {
line-height: 0;
padding-bottom: 0;
padding-top: 0;
}
}
1. A 1 second fade-out is started that goes from full opacity to fully transparent in the first 500
milliseconds. The second 500 milliseconds, the opacity stays at 0 (so fully transparent).
2. While the fade-out is happening, a second animation called shrink is also running. At first, it waits
500 ms, then it runs for 500 ms to shrink the height of the table row that gets deleted from the
normal height to zero.
Visually, the user will see the table row become transparent in 500 ms. Then the empty table row
starts to shrink until it has no height left.
Note how we use the CSS class .htmx-swapping to have the animation happen after the htmx
request has returned with the response from the server. Htmx automatically adds this CSS class to
the element that triggered the request. This is perfect for us to hook into to run our CSS animation.
Try out the delete link on the list of contacts and enjoy the animation.
The reason for this is that the redirect was triggered from an htmx request and htmx will honor the
redirect, but it will of course add the Hx-Request attribute in the request header to indicate that this
new request to /contacts to show the contacts list is also an htmx request.
In the viewContacts method of ContactsController, we only return the <tbody> when this is an
htmx request, resulting in the bug we observe.
To fix this, we can check if the request came from the delete-button. If so, we return the full page,
not just the <tbody>:
com.modernfrontendshtmx.contactsapp.contact.web.ContactController
@GetMapping
public String viewContacts(Model model,
@RequestParam(value = "q", required = false)
String query,
@RequestParam(value = "page", required =
false, defaultValue = "0") int page,
HtmxRequest htmxRequest) {
Page<Contact> contactsPage;
if (query != null) {
model.addAttribute("query", query);
contactsPage = service.searchContacts(query, page);
} else {
contactsPage = service.getAll(page);
}
model.addAttribute("page", contactsPage.number());
model.addAttribute("size", contactsPage.size());
model.addAttribute("totalElements", contactsPage.totalElements());
model.addAttribute("contacts", contactsPage.values());
if (htmxRequest.isHtmxRequest()
&& !"delete-button".equals(htmxRequest.getTriggerId())) { ①
return "contacts/list :: tbody";
} else {
return "contacts/list";
}
}
Now the list of contacts are shown properly again after the redirect.
9.11.1. Archiver
The first thing we need is a supporting class to do the actual archiving.
package com.modernfrontendshtmx.contactsapp.contact.service;
import com.modernfrontendshtmx.contactsapp.contact.Contact;
import
com.modernfrontendshtmx.contactsapp.contact.repository.ContactRepository
;
import
com.modernfrontendshtmx.contactsapp.infrastructure.repository.Page;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;
@Component
public class Archiver {
private final ExecutorService executorService;
private final ContactRepository contactRepository;
private final Map<ArchiveId, ArchiveProcessInfo> archives = new
HashMap<>();
archives.get(id).setStatus(ArchiveProcessInfo.Status
.COMPLETE);
return builder.toString(); ⑥
} catch (Exception e) {
archives.get(id).setStatus(ArchiveProcessInfo.Status
.FAILED);
return null;
}
});
archives.get(id).setFuture(future);
return id;
}
.append(",")
.append(contact.getEmail())
.append(",")
.append(contact.getPhone())
.append(System.lineSeparator());
}
package com.modernfrontendshtmx.contactsapp.contact.service;
import java.util.UUID;
package com.modernfrontendshtmx.contactsapp.contact.service;
import org.springframework.util.Assert;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicInteger;
enum Status { ①
RUNNING,
COMPLETE,
FAILED
}
}
package com.modernfrontendshtmx.contactsapp;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@Configuration
@Bean
public ExecutorService executorService() {
return Executors.newCachedThreadPool();
}
templates/contacts/list.html
Inject the Archiver into the ContactController via the constructor. After that, add a method to
start the archiving operation:
com.modernfrontendshtmx.contactsapp.contact.web.ContactController
@PostMapping("/archives")
@HxRequest
public String createArchive(Model model) {
ArchiveId archiveId = archiver.startArchiving();
ArchiveProcessInfo processInfo = archiver.getArchiveProcessInfo
(archiveId);
model.addAttribute("archiveId", archiveId.value());
model.addAttribute("status", processInfo.getStatus());
model.addAttribute("progress", processInfo.getProgress());
return "contacts/archive";
}
This controller method is called by htmx when the button is clicked and returns the
contacts/archive.html template. In that template, we need to handle the 3 possible states that
the archiving process might be in: RUNNING, FAILED or COMPLETE:
templates/contacts/archive.html
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:hx="http://www.w3.org/1999/xhtml">
<body>
<div id="archive-ui" hx-target="this" hx-swap="outerHTML" class="h-8 w-
80">
<th:block th:if="${status.name() == 'RUNNING'}"> ①
<div hx:get="@{/contacts/archives/{id}(id=${archiveId})}" hx-
trigger="load delay:500ms"> ②
<div id="progress-container" class="progress-container">
<div id="progress-bar" class="progress-bar"
th:aria-valuenow="${progress}"
th:style="|width:${progress}%|"></div> ③
</div>
</div>
</th:block>
<th:block th:if="${status.name() == 'COMPLETE'}"> ④
<div>Completed.</div>
</th:block>
<th:block th:if="${status.name() == 'FAILED'}"> ⑤
<div>Failed!</div>
</th:block>
</div>
</body>
</html>
#progress-wrapper {
width: 25%;
}
.progress-container {
height: 20px;
margin-bottom: 20px;
overflow: hidden;
background-color: #f5f5f5;
border-radius: 4px;
box-shadow: inset 0 1px 2px rgba(0,0,0,.1);
}
.progress-bar {
float: left;
width: 0;
height: 100%;
font-size: 12px;
line-height: 20px;
color: #fff;
text-align: center;
background-color: #337ab7;
box-shadow: inset 0 -1px 0 rgba(0,0,0,.15);
transition: width .5s ease;
}
com.modernfrontendshtmx.contactsapp.contact.web.ContactController
@GetMapping("/archives/{id}")
@HxRequest
public String getArchive(Model model,
@PathVariable("id") UUID id) {
ArchiveId archiveId = new ArchiveId(id);
ArchiveProcessInfo processInfo = archiver.getArchiveProcessInfo
(archiveId);
model.addAttribute("archiveId", archiveId.value());
model.addAttribute("status", processInfo.getStatus());
model.addAttribute("progress", processInfo.getProgress());
return "contacts/archive";
}
We now have a button to start the archiving and a progress bar while the archiving is busy.
Figure 44. Progress bar shows the current progress of the archiving
At the end, the message "Completed" is shown. To be actually useful, we should show a download
link, so let’s do this now.
templates/contacts/archive.html
</div>
</th:block>
<body> if you want to have a more SPA-feel to the application. If you do this on this
application, it would also boost the download link which would not work. To avoid
that, add hx-boost="false" to the <a> element.
com.modernfrontendshtmx.contactsapp.contact.web.ContactController
@GetMapping("/archives/{id}")
public void downloadArchive(@PathVariable("id") UUID id,
HttpServletResponse response) throws
ExecutionException, InterruptedException, IOException {
ArchiveId archiveId = new ArchiveId(id);
ArchiveProcessInfo processInfo = archiver.getArchiveProcessInfo
(archiveId);
String archive = processInfo.getFuture().get();
response.setContentType("text/csv");
response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
contentDisposition.toString());
response.getOutputStream().write(archive.getBytes(StandardCharsets
.UTF_8));
response.flushBuffer();
}
9.12. Summary
This concludes our contacts application implementation. Throughout the chapter, we used htmx to
implement various patterns such as click to load, endless scroll, active search, load polling, …
Web components are similar, but they can be used with any framework. Moreover, they can be used
with server-side rendering as well, so we can use them in our Spring Boot with Thymeleaf projects.
Web Components is a suite of different technologies allowing you to create reusable custom
elements — with their functionality encapsulated away from the rest of your code — and utilize
them in your web apps.
There are quite a lot of free component libraries that you can incoporate into your applications:
• Material web
• Vaadin Web Components
• SAP Web Components
• Shoelace
Shoelace is available as a webjar, so we can add it to the Maven pom.xml to have it as a dependency.
After that we just add the link to the CSS and JavaScript files and we can start to use it.
The ttcli command line tool also has support for it, so let’s just create a project with Shoelace
support to get started quickly:
• Group: com.modernfrontendshtmx
• Artifact: github-tree
<dependency>
<groupId>org.webjars.npm</groupId>
<artifactId>shoelace-style__shoelace</artifactId>
<version>2.9.0</version>
</dependency>
Figure 46. The finished application showing the release notes of the 4.2.0 release of the Error Handling Spring
Boot Starter project
pom.xml
<dependency>
<groupId>org.kohsuke</groupId>
<artifactId>github-api</artifactId>
<version>1.316</version>
</dependency>
• One that will return the name of the repositories for a given username.
• Another one that will return all the released versions for a given repository (of a given user).
package com.modernfrontendshtmx.githubtree;
import org.kohsuke.github.GHRepository;
import org.kohsuke.github.GHUser;
import org.kohsuke.github.GitHub;
import org.kohsuke.github.PagedIterable;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.List;
@Component
public class GitHubGateway {
public List<String> getRepositories(String username) {
try {
GitHub github = GitHub.connectAnonymously(); ①
GHUser githubUser = github.getUser(username); ②
PagedIterable<GHRepository> ghRepositories = githubUser
.listRepositories(); ③
return ghRepositories.toList().stream()
.map(GHRepository::getName)
.toList(); ④
} catch (IOException e) {
throw new RuntimeException(e);
}
}
① Connect to GitHub.
② Search for the user with the given username.
⑥ Get all the releases and extract the id and name of those releases.
We will start by rendering the list of repositories immediately upon loading the page. Afterward, we
will show how to lazily load it.
Inject the GitHubGateway into the controller and add the repositories to the model. I have the
username hardcoded to keep things simple, but feel free to add an inputfield to allow the user to
dynamically select the username.
package com.modernfrontendshtmx.githubtree;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import java.util.List;
@Controller
@RequestMapping("/")
public class HomeController {
private static final String USERNAME = "wimdeblauwe";
@GetMapping
public String index(Model model) {
List<String> repositories = gateway.getRepositories(USERNAME);
model.addAttribute("repositories", repositories);
return "index";
}
}
In the index.html, we will render the repositories using the Shoelace <sl-tree> and <sl-tree-
item> web components. See Tree and Tree Item for more info on those.
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/main}">
<body>
<div layout:fragment="content" class="container mx-auto my-4">
<h1 class="text-xl">Release notes viewer</h1>
<div class="flex">
<div class="w-1/3 m-2">
<div>
<sl-tree id="repositories-tree"
selection="leaf"> ①
<sl-tree-item>GitHub Repositories
<sl-tree-item th:each="repository :
${repositories}"> ②
<span th:text="${repository}"></span> ③
</sl-tree-item>
</sl-tree-item>
</sl-tree>
</div>
</div>
<div class="w-2/3 m-2">
<div id="release-notes-container" class="p-4 bg-amber-50
prose prose-h2:mt-1">
<div>Select a version on the left to view the release
notes.</div>
</div>
</div>
</div>
</div>
<th:block layout:fragment="script-content">
<!-- Add additional scripts there that are only needed for this page
(Application wide scripts should be added in layout/main.html) --->
</th:block>
</body>
</html>
① The <sl-tree> web component defines the root of the tree. We set the selection attribute to
leaf since we only want to allow selection of leaf nodes in the tree.
② Iterate over the repositories and add an <sl-tree-item> element for each.
Start the Spring Boot application with the local profile and run npm run build && npm run
watch to test it. You should see a collapsed tree. Opening the tree should show the list of
repositories.
Update the HomeController to move the call to the GitHub API into a separate controller method
that we can call from htmx:
com.modernfrontendshtmx.githubtree.HomeController
@GetMapping
public String index(Model model) { ①
return "index";
}
@HxRequest
@GetMapping("/repositories") ②
public String repositoriesTree(Model model) {
List<String> repositories = gateway.getRepositories(USERNAME); ③
model.addAttribute("repositories", repositories);
return "repositories-tree :: repositories"; ④
}
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<th:block th:fragment="repositories">
GitHub Repositories
<sl-tree-item th:each="repository : ${repositories}">
<span th:text="${repository}"></span>
</sl-tree-item>
</th:block>
</html>
Finally, we need to update the index.html to call the new endpoint. A tree item has a property lazy
that enables the lazy loading behavior. It has an event called sl-lazy-load that is triggered when
the lazy loading should start. The event is perfect to use as a trigger for htmx to do a GET request to
our endpoint. In code, it looks like this:
src/main/resources/templates/index.html
<sl-tree id="repositories-tree"
selection="leaf">
<sl-tree-item lazy
hx-trigger="sl-lazy-load"
hx:get="@{/repositories}"
hx-swap="innerHTML"> ①
GitHub Repositories
</sl-tree-item>
</sl-tree>
• hx-trigger to trigger the request when the tree item gets expanded.
If you test, you should notice that the page now loads much faster. Expanding the tree will now take a
while, but luckily the shoelace component has a built-in spinner while it loads.
Add a new method to the controller that returns the list of releases given a repository id:
com.modernfrontendshtmx.githubtree.HomeController
@HxRequest
@GetMapping("/repositories/{id}/releases")
public String repositoryReleasesTree(@PathVariable("id") String id,
Model model) {
List<GitHubGateway.RepositoryRelease> releases = gateway
.getRepositoryReleases(USERNAME, id);
model.addAttribute("repositoryName", id);
model.addAttribute("releases", releases);
return "repositories-tree :: releases";
}
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<th:block th:fragment="repositories">
GitHub Repositories
<sl-tree-item th:each="repository : ${repositories}"
lazy
hx-trigger="sl-lazy-load consume"
hx:get="@{/repositories/{name}/releases(name=${repository})}"> ①
<span th:text="${repository}"></span>
</sl-tree-item>
</th:block>
<th:block th:fragment="releases">
<span th:text="${repositoryName}"></span> ②
<sl-tree-item th:if="${releases.empty}" disabled> ③
No releases found
</sl-tree-item>
<sl-tree-item th:each="release : ${releases}"> ④
<span th:text="${release.name}"></span>
</sl-tree-item>
</th:block>
</html>
① Add the lazy, hx-trigger and hx-get attributes to lazily retrieve the list of releases. Note how
we need to add consume to avoid the sl-lazy-load would propagate to the parent tree items.
The final part of the puzzle is now updating the center of the application with the actual release notes.
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark</artifactId>
<version>0.20.0</version>
</dependency>
We can update the HomeController with a new endpoint to retrieve the release notes, converted to
HTML:
@HxRequest
@GetMapping("/repositories/{name}/releases/{id}")
public String repositoryReleaseNotes(@PathVariable("name") String
repositoryName,
@PathVariable("id") long releaseId,
Model model) {
String repositoryRelease = gateway.getRepositoryRelease(USERNAME,
repositoryName, releaseId); ①
model.addAttribute("releaseBody", renderMarkdown(
repositoryRelease)); ②
return "repositories-tree :: release-body"; ③
}
com.modernfrontendshtmx.githubtree.GitHubGateway
src/main/resources/templates/repositories-tree.html
<th:block th:fragment="releases">
<span th:text="${repositoryName}"></span>
<sl-tree-item th:if="${releases.empty}" disabled>
No releases found
</sl-tree-item>
<sl-tree-item th:each="release : ${releases}"
hx-target="#release-notes-container"
hx-indicator="#release-notes-loading-indicator"
hx:get="@{/repositories/{name}/releases/{id}(name=${repositoryName},id=$
{release.id})}"> ①
<span th:text="${release.name}"></span>
</sl-tree-item>
</th:block>
</div>
Add the loading indicator for when the release notes are retrieved to index.html:
src/main/resources/templates/index.html
5 text-black" xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10"
stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0
12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-
2.647z"></path>
</svg>
</div>
We should now have a fully functional app that will use htmx to lazy load the repositories, releases
and release notes and update everything without page refreshes.
10.4. Summary
This chapter showed how to combine a web components library with htmx. The important part here is
that the library exposes events which we can hook into to trigger htmx requests.
If you want to see another example, there is a 2 part blog entry on my website that shows how to
combine Thymeleaf, Shoelace and Alpine to show toast notifications. This is a screenshot of it:
We will use the htmx SSE extension to show a small demo of how we can use this in a Spring Boot
application using Webflux.
• Group: com.modernfrontendshtmx
• Artifact: ssedemo
We will use daisyUI, a free component library for Tailwind CSS this time.
Our demo will allow to upload a file to the server where it will be processed line by line. The
"processing" will just be a 10 millisecond sleep for demonstration purposes. While the processing is
happening, a progress bar and a log output will be shown in the browser and continuously updating.
We start by adding Google Guava library to be able to use the CharStreams class to read an
InputStream into lines.
pom.xml
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>32.1.2-jre</version>
</dependency>
package com.modernfrontendshtmx.ssedemo;
import com.google.common.io.CharStreams;
import com.google.common.io.LineProcessor;
import org.springframework.stereotype.Component;
import java.io.*;
import java.util.HashSet;
import java.util.Set;
@Component
public class FileProcessor {
private final Set<ProgressListener> progressListeners = new
HashSet<>();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
@Override
public boolean processLine(String line) throws IOException {
// Fake some import work
sleepQuietly();
numberOfLinesProcessed++;
notifyProgressListeners(line); ⑥
return true;
}
@Override
public Void getResult() {
return null;
}
① The process method allows processing an InputStream which we will receive from the controller
we are going to write.
② To know the total number of lines, we need to read the file a first time.
③ We read the file a second time here to actually process it line by line using the MyLineProcessor
inner class.
package com.modernfrontendshtmx.ssedemo;
package com.modernfrontendshtmx.ssedemo;
import org.springframework.util.Assert;
We will start with a simple form on index.html that allows to upload a file without showing any
progress:
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/main}"
xmlns:hx="http://www.w3.org/1999/xhtml">
<body>
<div layout:fragment="content">
<div class="m-4">
<form hx-encoding="multipart/form-data" hx:post="@{/}" hx-
disabled-elt=".disable-during-request" hx-swap="none"> ①
<input type="file" name="file"
③ Button to submit the request. It uses the daisyUI btn btn-primary CSS classes for styling.
Let’s now update HomeController to connect the form with the FileProcessor:
package com.modernfrontendshtmx.ssedemo;
import io.github.wimdeblauwe.htmx.spring.boot.mvc.HxRequest;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
@Controller
@RequestMapping("/")
public class HomeController {
private final FileProcessor fileProcessor;
this.fileProcessor = fileProcessor;
}
@GetMapping
public String index(Model model) {
return "index";
}
@HxRequest
@PostMapping
@ResponseBody ②
public void runImport(@RequestParam("file") MultipartFile file)
throws IOException { ③
fileProcessor.process(file.getInputStream()); ④
}
② Annotate with @ResponseBody to avoid that Spring would think we want to render some view.
③ Add a request parameter with the name file, matching the name we used in our <input>
element.
④ Get the input stream from the file and pass it to the processor for processing.
Run it with the local Spring profile and call npm run build && npm run watch to setup the live
reload.
If you select a text file and press 'Import', then the elements on the page will be disabled for the time
of the processing. That is already nice, but it would be better if we could show what is happening
during the processing.
To push information over Server-sent events, we need to expose an endpoint that the browser can
connect on. We can use SseEmitter which works for Spring Web, but we can also leverage Spring
Webflux and stream a Flux<ServerSentEvent<T>>. We can perfectly add the spring-boot-
starter-webflux dependency in our project and just use it for SSE support, but keep using the
normal Spring MVC for the rest.
pom.xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
Before we can expose the endpoint, we need something that will give us a stream of progress events.
Let’s create the SseBroker class for this:
package com.modernfrontendshtmx.ssedemo;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Sinks;
import reactor.util.concurrent.Queues;
import java.time.Duration;
import java.util.List;
@Component
public class SseBroker {
private final Sinks.Many<ProgressEvent> eventPublisher;
@PostConstruct
void init() {
progressListener = (progress, message) ->
eventPublisher.tryEmitNext(new ProgressEvent(progress,
message)); ③
fileProcessor.addProgressListener(progressListener); ④
}
@PreDestroy
void destroy() {
fileProcessor.removeProgressListener(progressListener); ⑤
}
.multicast()
.onBackpressureBuffer(Queues.SMALL_BUFFER_SIZE, false);
}
② Create a sink where we will be able to push objects on to have them stream to a Flux.
⑤ Public method so others can subscribe to the Flux that sends out the progress updates.
⑥ We have no idea how quickly the FileProcessor can process, but we want to avoid updating the
browser for every line. By using buffer, we can turn a Flux<ProgressEvent> into a
Flux<List<ProgressEvent>> that will send out a new element in the Flux at most once a
second. This will avoid overloading the browser with many rapid requests.
⑦ Record to send the Progress and the String as a single object.
With this in place, we can expose our SSE endpoint in the HomeController:
com.modernfrontendshtmx.ssedemo.HomeController
@GetMapping("/progress")
public Flux<ServerSentEvent<String>> progress() {
Flux<List<SseBroker.ProgressEvent>> updates = broker
.subscribeToUpdates(); ①
return updates
.map(events -> ServerSentEvent.<String>builder() ②
.data(events.stream()
.map(progressEvent -> "<div>%s</div>"
.formatted(replaceNewLines(progressEvent.message())))
.collect(Collectors.joining())) ③
.build()
)
.doOnSubscribe(subscription -> LOGGER.debug("Subscription:
{}", subscription))
.doOnCancel(() -> LOGGER.debug("cancel"))
.doOnError(throwable -> LOGGER.debug(throwable.getMessage(),
throwable))
.doFinally(signalType -> LOGGER.debug("finally: {}",
signalType));
}
③ Because we buffer the progress messages, we need to join the different messages together, each
in a separate <div>. We also replace any new lines with <br> (Not really needed here, but might
be good to know in case you need it).
You will also need to add an org.slf4j.Logger logger declaration at the top of the
HomeController class:
com.modernfrontendshtmx.ssedemo.HomeController
With this endpoint in place, we can update our HTML to listen to the SSE endpoint:
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/main}"
xmlns:hx="http://www.w3.org/1999/xhtml">
<body>
<div layout:fragment="content">
<div class="m-4" hx-ext="sse" sse-connect="/progress"> ①
<form hx-encoding="multipart/form-data" hx:post="@{/}" hx-
disabled-elt=".disable-during-request" hx-swap="none">
<input type="file" name="file"
class="file-input file-input-bordered w-full max-w-xs
disable-during-request"/>
<button class="btn btn-primary disable-during-request"
>Import</button>
</form>
</div>
<th:block layout:fragment="script-content">
<script type="text/javascript"
th:src="@{/webjars/htmx.org/dist/ext/sse.js}"></script> ③
</th:block>
</body>
</html>
① Declare to use the SSE extension via hx-ext="sse". Connect on the SSE endpoint via sse-
connect="/progress".
② Indicate that we want to add whatever messages we receive on the SSE endpoint to this <div>.
If you test, you should see the contents of the file appearing in the log section of the page:
If you check the developer tools, you can see the messages being streamed over the /progress
endpoint:
As with a normal request, htmx uses the innerHTML swap strategy by default. If we want to have all
the logs available at the end of the processing, we should append each Server-sent event we receive.
We can easily do this by setting hx-swap to beforeend.
Once we do that, we should also scroll to the bottom each time we append something.
Htmx has a nice way to run some JavaScript when an event happens by declaring a hx-on attribute.
We can just add the event name in the attribute like this:
Use the hx-on attribute to list for the htmx:afterSettle event and use some JavaScript to do the
scrolling.
<div id="progress-log"
class="mt-4 p-2 h-96 font-mono bg-gray-700 text-gray-100 overflow-
y-auto"
sse-swap="message"
hx-swap="beforeend"
hx-on::after-settle="this.scrollTo(0, this.scrollHeight);">
</div>
We already see each line being processed now, but it would be even better if we can show a progress
bar to give a better indication of how long the processing might still take.
Each Server-sent event can have an associated event name. By default, this is just message, but we
can use different names to stream different event types. We will use this to send out log events and
progress events over the same SSE connection and update different parts of our web application.
com.modernfrontendshtmx.ssedemo.HomeController
@GetMapping("/progress")
public Flux<ServerSentEvent<String>> progress() {
Flux<List<ProgressEvent>> updates = broker.subscribeToUpdates();
return updates
.flatMap(events -> { ①
return Flux.just( ②
createLogEvent(events),
createProgressEvent(events)
)
.filter(Objects::nonNull); ③
}
)
.doOnSubscribe(subscription -> LOGGER.debug("Subscription:
{}", subscription))
.doOnCancel(() -> LOGGER.debug("cancel"))
.doOnError(throwable -> LOGGER.debug(throwable.getMessage(),
throwable))
.doFinally(signalType -> LOGGER.debug("finally: {}",
signalType));
}
.build();
}
① Use flatMap instead of map as we will create two server-sent events now out of the
List<ProgressEvent> input.
④ Use the event(String) method to give a name to the event that sends the HTML for the log
lines.
⑤ Use that same method to give the progress-event name to the HTML that will be the progress
bar updates.
⑥ From the List<ProgressEvent>, we get the highest progress value since that is where the
progress is currently at.
⑦ Generate the HTML for the progress bar.
⑧ If there are no progress events, the max() method will return an empty optional. If that is the case,
we will just return null and filter it out down the stream.
With this code in place, 2 events are now sent over the endpoint:
• log-event with the HTML that we will append to the central log viewer.
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/main}"
xmlns:hx="http://www.w3.org/1999/xhtml">
<body>
<div layout:fragment="content">
<div class="m-4" hx-ext="sse" sse-connect="/progress">
<form hx-encoding="multipart/form-data" hx:post="@{/}" hx-
disabled-elt=".disable-during-request" hx-swap="none">
<input type="file" name="file"
class="file-input file-input-bordered w-full max-w-xs
disable-during-request"/>
<button class="btn btn-primary disable-during-request"
>Import</button>
</form>
</div>
① Set sse-swap to progress-event so that the HTML of that server-sent event gets swapped in
here.
② Change the sse-swap from message to log-event.
If you want the progress bar to update more often, update the buffer size in
SseBroker to a smaller value:
com.modernfrontendshtmx.ssedemo.SseBroker
public Flux<List<ProgressEvent>> subscribeToUpdates() {
return this.eventPublisher.asFlux()
.buffer(Duration.ofMillis(500));
Instead of sending multiple events over the endpoint, you can send a single event
and have htmx trigger an additional request towards the server. See Trigger Server
Callbacks for more information about that.
Pushing live data from the server is great, but your users should be sure that the connection is still
working at all times. Otherwise, they might just be waiting for updates that will never arrive because
the connection is broken. Luckily, htmx has support for monitoring the connection through the
htmx:sseError and htmx:sseOpen events.
src/main/resources/templates/index.html
<div class="m-4"
hx-ext="sse"
sse-connect="/progress"
hx-on::sse-
error="document.getElementById('connectionAlert').classList.remove('hidd
en')"
hx-on::sse-
open="document.getElementById('connectionAlert').classList.add('hidden')
"
> ①
<div id="connectionAlert"
class="alert alert-warning mb-4 hidden"> ②
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-
current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-
linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0
4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-
2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
<span>Warning: No connection with server anymore. Live
updates are disabled.</span>
</div>
① Add an event listener via hx-on for the htmx:sseError and htmx:sseOpen events. The listeners
will add or remove the hidden CSS class to show or hide the alert message. Although the alert is
hidden by default, we also need to hide it on htmx:sseOpen to hide the error again after it was
made visible.
② Add an alert which is hidden by default.
In theory, that is all there is to it. But to make it really reliable, you should send out heart beat events
from the server. If you do not, the message that the connection is gone will appear, but if the
connection restores, the warning will only go away when the first SSE event arrives. By adding a
regular heartbeat, you ensure it will never take long for a new message to arrive:
com.modernfrontendshtmx.ssedemo.HomeController
@GetMapping("/progress")
public Flux<ServerSentEvent<String>> progress() {
Flux<ServerSentEvent<String>> heartbeat = Flux.interval(Duration
.ofSeconds(5)) ①
.map(it -> ServerSentEvent.<String>builder().event
("heartbeat").build()); ②
Flux<List<ProgressEvent>> updates = broker.subscribeToUpdates();
return Flux.merge(heartbeat, ③
updates
.flatMap(events -> Flux.just(
createLogEvent(events),
createProgressEvent(events))
.filter(Objects::nonNull)
))
.doOnSubscribe(subscription -> LOGGER.debug("Subscription:
{}", subscription))
.doOnCancel(() -> LOGGER.debug("cancel"))
.doOnError(throwable -> LOGGER.debug(throwable.getMessage(),
throwable))
.doFinally(signalType -> LOGGER.debug("finally: {}",
signalType));
}
1. If you use the npm run watch script and test on http://localhost:3000, you should stop the script
and start it again. Stopping the Spring Boot application will not show the connection as down as
the proxy that the script starts would still be up and running.
2. To test what happens if the Spring Boot application itself is stopped, be sure to test on
http://localhost:8080 (so without the live reload proxy in between).
This concludes our exploration of the SSE support in htmx. We will look at using Websockets for
bidirectional communication next.
11.2. Websockets
We will use the htmx WS extension to show a small demo of how we can use this in a Spring Boot
application using Webflux.
• Group: com.modernfrontendshtmx
• Artifact: wsdemo
To use Websockets in Spring Boot, we need to add the starter for it:
pom.xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
The idea is that htmx will send JSON from the browser towards our socket handler, and the socket
handler will send HTML to the browser which htmx will swap in.
Before we look at the actual implementation, we will write the HTML for our application. It is a very
bare-bones chat application which will allow any user accessing the application talk to all other users:
src/main/resources/templates/index.html
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout/main}" xmlns:hx-
on="http://www.w3.org/1999/xhtml">
<body>
<div layout:fragment="content">
<div hx-ext="ws"
ws-connect="/chatroom"
class="flex flex-col h-screen"
hx-on::ws-after-message="clearInput()"> ①
<div id="messages" class="grow">
<div class="chat chat-start">
<div class="chat-bubble">Welcome to the chat!</div>
</div>
</div>
<div class="flex mb-4">
<form id="form" ws-send class="flex gap-2 mx-2 w-full"> ②
<input id="chat-message-input"
name="chatMessage"
type="text"
class="input input-bordered w-full"
placeholder="Write a message"
autofocus> ③
<button class="btn btn-neutral">Chat</button>
</form>
</div>
</div>
</div>
<th:block layout:fragment="script-content">
<script type="text/javascript"
th:src="@{/webjars/htmx.org/dist/ext/ws.js}"></script> ④
<script>
function clearInput() { ⑤
let element = document.getElementById('chat-message-input');
element.value = '';
element.focus();
}
</script>
</th:block>
</body>
</html>
The handler in Spring Boot will receive the JSON and send a response back:
package com.modernfrontendshtmx.wsdemo;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
@Component
public class SocketHandler extends TextWebSocketHandler {
@Override
public void handleTextMessage(WebSocketSession session,
TextMessage message)
throws IOException {
@Override
public void afterConnectionEstablished(WebSocketSession session) {
sessions.add(session);
}
@Override
public void afterConnectionClosed(WebSocketSession session,
④ Get the value under the key chatMessage (Which is the name of the <input> element in the
HTML template).
⑤ Send a message back to the sender.
⑥ Loop over all connected browsers to send a message.
⑦ Send a text message over the web socket session.
Now we need to register our SocketHandler under the /chatroom url. For this, we need to
implement the WebSocketConfigurer and use the @EnableWebSocket annotation:
package com.modernfrontendshtmx.wsdemo;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import
org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import
org.springframework.web.socket.config.annotation.WebSocketHandlerRegistr
y;
@Configuration
@EnableWebSocket ①
public class WebSocketConfig implements WebSocketConfigurer { ②
We can now test if messages are sent back and forth. Start the application with the local profile and
run npm run build to ensure all the frontend templates are available.
It is not possible to use the live reload of npm run watch if you use websockets.
Browsersync does not seem to support it. So remember to run npm run build
again each time and manually refresh your browser.
If you load the page with the browser dev tools open, you can see there is a websocket connection on
the /chatroom endpoint. If you send the message "Hello" for example, you will see the JSON that
htmx sends towards the server. The reply is currently just the exact text echoed back, which does not
do anything visually in the browser, but at least we know that the connection works.
We can now update sending some HTML back, so we have a real chat client application.
We need to actually send some HTML back so htmx can swap that into view and we get a functioning
chat client. We could just inline the HTML in the controller like we did with the Server-sent Events, but
it is nicer if we can write the snippets as Thymeleaf fragments.
Let’s first write the fragments and then update our SocketHandler to render the fragments.
src/main/resources/templates/index.html
<template> ①
<div id="messages" hx-swap-oob="beforeend" th:fragment="incoming-
message(message)"> ②
<div class="chat chat-start">
<div class="chat-bubble" th:text="${message}">Welcome to the
chat!</div>
</div>
</div>
</template>
<template>
<div id="messages" hx-swap-oob="beforeend" th:fragment="user-
message(message)"> ③
<div class="chat chat-end">
<div class="chat-bubble" th:text="${message}"></div>
</div>
</div>
</template>
① Use the HTML <template> tag to avoid that the HTML is rendered into view. This is just a
convenience if you don’t want to write a separate file with your fragments.
② Declare the fragment as incoming-message with a single parameter message. We also specify
how htmx should swap the HTML onto the page via the id and the hx-swap-oob attributes. The
id references the id of the HTML element on the page where the snippet should be swapped in.
The hx-swap-oob defines the method of swapping. In this case, we use beforeend so the html is
appended to the list of messages.
③ Similar fragment called user-message. This fragment shows the speech bubble on the right side.
The incoming-message fragment shows the speech bubble on the left side.
package com.modernfrontendshtmx.wsdemo;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import org.thymeleaf.context.Context;
import org.thymeleaf.spring6.SpringTemplateEngine;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CopyOnWriteArrayList;
@Component
public class SocketHandler extends TextWebSocketHandler {
@Override
public void handleTextMessage(WebSocketSession session,
TextMessage message)
throws IOException {
@Override
public void afterConnectionEstablished(WebSocketSession session) {
sessions.add(session);
}
@Override
public void afterConnectionClosed(WebSocketSession session,
CloseStatus status) throws Exception {
sessions.remove(session);
}
if (webSocketSession.equals(currentSession)) { ③
webSocketSession.sendMessage(new TextMessage
(userMessageHtml(message)));
} else {
webSocketSession.sendMessage(new TextMessage
(incomingMessageHtml(message)));
}
} catch (IOException e) {
LOGGER.debug("Unable to send message to {}",
webSocketSession);
}
}
}
③ While looping over all sessions, check if the given session is equal to the current session. That way,
we know if we should send the HTML of the user-message template or the incoming-message
template.
④ Helper method to render the user-message template.
⑤ Add the message under the message key in the context since the fragment requires that to
render.
⑥ Call the process method with the name of the Thymeleaf template as first argument ("index")
and the name of the fragment inside the template as the second argument ("user-message").
Figure 57. Two browser windows chatting with each other over a websocket connection
11.3. Summary
In this chapter, we have seen how to use Server-sent events and websockets to push data from the
server to the browser. In most cases, Server-sent events are simpler and enough for most needs.
However, websocket support is there in case you need it.
Feel free to contact me at wim.deblauwe@gmail.com, or via Twitter, Mastodon or LinkedIn if you have
any remarks on the book. I also love to hear about what you have built with htmx, so do let me know
about it!
As a final gift to you, my dear reader, I give you some links to further explore htmx:
• htmx.org is the official website of the project. They have a lot of documentation and examples, but
what is also very nice are the various essays that Carson has written. It gives a lot of background
information on the hypermedia idea behind htmx, info on when and when not to use hypermedia,
why you can use htmx even if you have a mobile app as well (hint: most likely the users of your
mobile app are not the users of your web application).
• https://twitter.com/htmx_org is the official Twitter account. Expect lots of fun and crazy memes :-)
• My personal blog where I continue to write about Spring Boot, Thymeleaf and htmx.
• If you are stuck on a particular problem, Stack Overflow is a good place to ask your question. Be
sure to tag it with the htmx tag.
• Many htmx users are talking about htmx and the things they do with it on the htmx Discord
Server. There are various channels there dedicated to a particular backend technology like Java
and Kotlin, but also PHP, Phython, .NET, …
• Presentations about htmx
◦ HTMX: Web 1.0 with the benefits of Web 2.0 without the grift of Web 3.0-at JFall 2023
◦ htmx + Flask: Modern Python Web Apps
• Videos on htmx
◦ htmx in 100 seconds
◦ From React to htmx on a real-world SaaS product: we did it, and it’s awesome!
◦ Modern frontends with Thymeleaf and htmx - My presentation from Devoxx 2022
1.0.0
December 3rd, 2023
• Initial release