[go: up one dir, main page]

0% found this document useful (0 votes)
1K views269 pages

Modern Frontends With HTMX

Uploaded by

Thando Mafela
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
1K views269 pages

Modern Frontends With HTMX

Uploaded by

Thando Mafela
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
You are on page 1/ 269

Modern frontends

with htmx
Use htmx with Spring Boot and Thymeleaf to build dynamic
and interactive web applications

Wim Deblauwe

Version 1.0.0, 2023-12-03


Table of Contents
Modern frontends with htmx . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1
Foreword . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2
Acknowledgements . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3
Introduction . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4
Source code . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5
1. Technologies used in the book . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6
1.1. htmx . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6
1.2. Spring Framework. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6
1.3. Spring Boot. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7
1.4. Thymeleaf. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7
1.5. Alpine . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7
1.6. Tailwind CSS . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7
2. Getting started . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9
2.1. Prerequisites . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9
2.1.1. macOS/Linux . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9
2.1.2. Windows . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 9
2.2. ttcli. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10
2.2.1. What is it? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10
2.2.2. Installation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 10
2.2.3. Project generation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11
2.2.4. Run the application . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 15
2.3. IntelliJ IDEA plugins . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16
2.4. Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 16
3. Starting with htmx . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 18
3.1. Hello world example. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 18
3.2. Triggers. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23
3.2.1. Trigger modifiers . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23
3.2.2. Trigger filters . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 23
3.2.3. Special events. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 24
3.2.4. Example . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 25
3.3. Targets . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 32
3.4. Swapping . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 33
3.5. HTTP verbs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 37
3.6. Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 38
4. Request and response headers . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 39
4.1. Request headers . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 39
4.2. Response headers. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 41
4.3. Htmx-spring-boot library . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 42
4.4. Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 45
5. Project 1: TodoMVC . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 46
5.1. Initial implementation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 46
5.2. Boost the application . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 58
5.3. Fine-grained htmx implementation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 59
5.3.1. Add a new todo item. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 60
5.3.2. Update number of items . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 64
5.3.3. Mark item as completed. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 67
5.3.4. Delete item . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 69
5.4. Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 71
6. Out of Band Swaps . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 72
6.1. General principles . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 72
6.2. Example . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 72
6.2.1. Project generation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 73
6.2.2. Domain model . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 74
6.2.3. UI setup. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 77
6.2.4. Input of time registration durations . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 80
6.2.5. Day totals . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 84
6.3. Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 87
7. Client-side scripting . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 88
7.1. Vanilla JavaScript . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 88
7.1.1. Project setup . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 89
7.1.2. Numbers Api . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 89
7.1.3. Web UI. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 92
7.1.4. Progress indicator . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 94
7.1.5. Error handling . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 96
7.2. AlpineJS. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 101
7.2.1. Project setup . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 102
7.2.2. Domain model . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 103
7.2.3. Inline editing of issue summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 111
7.2.4. Using drag and drop . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 124
7.3. Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 133
8. Security . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 134
8.1. Htmx and security. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 134
8.2. Bookmarks . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 134
8.2.1. Initialize project . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 134
8.2.2. Add Spring Security . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 134
8.3. Classic Thymeleaf setup. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 136
8.4. Use htmx for adding a bookmark. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 143
8.5. Deleting a bookmark . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 145
8.5.1. Delete with hidden input . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 146
8.5.2. Delete with meta tags. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 147
8.5.3. Delete with Thymeleaf inlining syntax for JavaScript . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 148
8.6. Handle logout . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 149
8.7. Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 151
9. Project 2: Contact application. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 152
9.1. Setup project . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 152
9.2. Add a contact. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 160
9.3. Search . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 168
9.4. View contact. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 171
9.5. Edit and delete a contact . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 174
9.6. Delete using htmx. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 180
9.7. Inline validation of duplicate email addresses . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 184
9.7.1. Implement custom validator . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 184
9.7.2. Trigger validation while typing . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 187
9.7.3. Improve the user experience . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 190
9.7.4. Use hx-validate to avoid unnecessary requests . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 192
9.8. Pagination . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 193
9.8.1. Generate contacts using Datafaker . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 193
9.8.2. Manual pagination . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 193
9.8.3. Click to load . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 197
9.8.4. Infinite scroll. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 199
9.9. Active search . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 199
9.9.1. Search pagination . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 199
9.9.2. Search on type . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 202
9.10. Delete row from list . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 204
9.10.1. Fix the delete button redirect . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 206
9.11. Archive list of contacts . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 207
9.11.1. Archiver. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 208
9.11.2. Create an archive . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 212
9.11.3. Download link . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 216
9.12. Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 218
10. Web components . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 219
10.1. What are web components? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 219
10.2. Integrating Shoelace. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 219
10.3. GitHub Tree . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 220
10.3.1. GitHub API . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 221
10.3.2. Make it lazy. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 225
10.3.3. Add repository releases . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 227
10.3.4. Show release notes. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 228
10.4. Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 232
11. Server-sent events & websockets . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 234
11.1. Server-sent events . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 234
11.1.1. What are Server-sent events? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 234
11.1.2. Using the htmx sse extension . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 234
11.2. Websockets . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 252
11.2.1. What are websockets? . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 252
11.2.2. Using the htmx ws extension . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 252
11.3. Summary . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 262
12. Closing . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 263
Appendix A: Change log. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 264
Modern frontends with htmx

Modern frontends with htmx


© 2023 Wim Deblauwe. All rights reserved. Version 1.0.0.

Published by Wim Deblauwe (wim.deblauwe@gmail.com)

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.

Cover Design: Jasmine Verhaeghe

Modern frontends with htmx | 1


Modern frontends with htmx

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.

Enjoy the book, we are so back!

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

Chapter 1. Technologies used in the book


This book uses a few libraries and frameworks throughout. Some chapters use something extra which
will be explained at that time. Here, we list the things that will be used constantly throughout the
book.

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.

See the htmx website for more information.

1.2. Spring Framework


Spring Boot is based upon the Spring Framework, which is at its core a dependency-injection
container. Spring makes it easy to define everything in your application as loosely coupled
components which Spring will tie together at run time. Spring also has a programming model that
allows you to make abstractions from specific deployment environments.

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.

The specific subprojects used in this book are:

Spring Web MVC


Spring Web MVC is Spring’s web framework built on the Servlet API.

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.

6 | Chapter 1. Technologies used in the book


Modern frontends with htmx

1.3. Spring Boot


The Spring Boot website explains itself succinctly:

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.

Learn more about Thymeleaf at the Thymeleaf Documentation.

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

See the Alpine website for more info.

1.6. Tailwind CSS


Tailwind CSS is a utility-first CSS framework. If you want a good introduction, checkout the very
informative screencasts on the website.

In a nutshell, the goal of Tailwind is that you need almost no custom CSS. You apply ready-made
classes to your HTML.

Chapter 1. Technologies used in the book | 7


Modern frontends with htmx

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.

8 | Chapter 1. Technologies used in the book


Modern frontends with htmx

Chapter 2. Getting started

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.

1. Follow the SDKMAN! installation instructions at https://sdkman.io/install:

curl -s "https://get.sdkman.io" | bash

2. Install Java:

sdk install java 17.0.6-tem

 Use sdk list java to see a list of all possible Java versions that can be
installed.

3. Install Maven:

sdk install maven 3.8.7

4. Run mvn --version to see if both are configured correctly. The output should look similar to this:

wdb@Wims-MacBook-Pro ~ % mvn --version


Apache Maven 3.8.7 (b89d5959fcde851dcb1c8946a785a163f14e1e29)
Maven home: /Users/wdb/.sdkman/candidates/maven/current
Java version: 17.0.6, vendor: Eclipse Adoptium, runtime:
/Users/wdb/.sdkman/candidates/java/17.0.6-tem
Default locale: en_BE, platform encoding: UTF-8
OS name: "mac os x", version: "12.6.3", arch: "x86_64", family: "mac"

2.1.2. Windows
Use Chocolatey to install Java and Maven.

1. Follow the Chocolatey installation instructions at https://chocolatey.org/install.

Chapter 2. Getting started | 9


Modern frontends with htmx

2. Install Java

choco install temurin

3. Install Maven

choco install maven

4. Run mvn -v to see if both are configured correctly. The output should look similar to this:

Apache Maven 3.8.4 (9b656c72d54e5bacbed989b64718c159fe39b537)


Maven home: C:\ProgramData\chocolatey\lib\maven\apache-maven-3.8.4
Java version: 17.0.1, vendor: Eclipse Adoptium, runtime: C:\Program
Files\Eclipse Adoptium\jdk-17.0.1.12-hotspot
Default locale: nl_BE, platform encoding: Cp1252
OS name: "windows 10", version: "10.0", arch: "amd64", family:
"windows"

2.2. ttcli

2.2.1. What is it?


The easiest way to get started with Spring Boot is to create a project using Spring Initializr. This web
application allows you to generate a Spring Boot project with the option of including all the
dependencies you need.

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.

10 | Chapter 2. Getting started


Modern frontends with htmx

Download the latest release from https://github.com/wimdeblauwe/ttcli/releases.

If you are on macOS, you can install it using Homebrew:

brew install wimdeblauwe/homebrew-ttcli/ttcli

For Windows, you can use Chocolatey:

choco install ttcli

2.2.3. Project generation

Open a terminal and run ttcli init. Answer the questions like this:

• Group: com.modernfrontendshtmx

• Artifact: modern-frontends-htmx

• Project Name: Modern Frontends Htmx

• Spring Boot version: 3.1.4

• Live reload: NPM based with Tailwind CSS

• Web dependencies: htmx and Alpine.js

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:

Chapter 2. Getting started | 11


Modern frontends with htmx

Figure 1. Example run of the ttcli tool

The generated project has a file structure like this:

├── 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

12 | Chapter 2. Getting started


Modern frontends with htmx

│ │ │ └── css
│ │ │ └── application.css
│ │ └── templates
│ │ ├── index.html
│ │ └── layout
│ │ └── main.html
│ └── test
│ └── java
│ └── com
│ └── modernfrontendshtmx
│ └── modernfrontendshtmx
│ └── ModernFrontendsHtmxApplicationTests.java
└── tailwind.config.js

The most important things to know:

• 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.

Chapter 2. Getting started | 13


Modern frontends with htmx

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>

Let’s change it to:

<!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>

① Set a margin on the content.


② Increase the text font size.
③ Use a yellow background and some horizontal padding.

After these changes, we can now run the application.

14 | Chapter 2. Getting started


Modern frontends with htmx

2.2.4. Run the application

Start the Spring Boot application using the local profile. If you use IntelliJ IDEA, you can configure
this in the run configuration:

Figure 2. IntelliJ run configuration dialog

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.

However, if you use the community edition, you can add


-Dspring.profiles.active=local to the VM options to activate the local
profile.

 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:

java -Dspring.profiles.active=local -jar target/modern-


frontends-htmx-0.0.1-SNAPSHOT.jar

With the Java application running, open a terminal and run the following command:

npm run build && npm run watch

This will automatically open your default browser at http://localhost:3000. It should look like this:

Chapter 2. Getting started | 15


Modern frontends with htmx

Figure 3. Application running in the browser

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.

2.3. IntelliJ IDEA plugins


If you use IntelliJ IDEA as your editor like I do, then there are 2 plugins you can install to make
development a little nicer:

• Htmx Support by Hugo Mesquita


• Alpine.js Support by Chris Morrell

Those plugins give you extra code completation and syntax highlighting for htmx and Alpine
respectively.

2.4. Summary
In this chapter, you learned:

• How to install Java and Maven


• How to create a Spring Boot application using ttcli.

• How to use the live reload setup generated by ttcli.

16 | Chapter 2. Getting started


Modern frontends with htmx


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

Chapter 2. Getting started | 17


Modern frontends with htmx

Chapter 3. Starting with htmx

3.1. Hello world example


To get started with htmx, we will create a kind of hello world application. The application will have
"Hello World" on the screen with a button. Pressing the button will change the text to "Hello htmx"
without a page refresh.

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

• Project Name: Button Click Change

• Spring Boot version: 3.1.4

• Live reload: NPM based with Tailwind CSS

• Web dependencies: htmx

Update src/main/resources/index.html to have our starter text and the button:

src/main/resources/index.html

<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>
<button class="items-center justify-center px-6 py-2.5 text-
center text-white duration-200 bg-black border-2 border-black rounded-
full inline-flex hover:bg-transparent hover:border-black hover:text-
black focus:outline-none focus-visible:outline-black text-sm focus-
visible:ring-black">
Click me
</button>
</div>
</div>

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.

18 | Chapter 3. Starting with htmx


Modern frontends with htmx

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>

① htmx added as a webjar.


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>

Chapter 3. Starting with htmx | 19


Modern frontends with htmx

<button class="items-center justify-center px-6 py-2.5 text-


center text-white duration-200 bg-black border-2 border-black rounded-
full inline-flex hover:bg-transparent hover:border-black hover:text-
black focus:outline-none focus-visible:outline-black text-sm focus-
visible:ring-black"
th:hx-get="@{/htmx}"
hx-target="#message">
Click me
</button>
</div>
</div>
</body>
</html>

The 2 highlighted lines show the minimum we need to get started:

• 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.

Create a new Thymeleaf template htmx.html:

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>

Update HomeController to serve this template at /htmx:

package com.modernfrontendshtmx.buttonclickchange;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

20 | Chapter 3. Starting with htmx


Modern frontends with htmx

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"; ②
}
}

① React to a HTTP GET request on /htmx.

② Render the message fragment in the htmx Thymeleaf template.

You can now start the application. It should look like this:

Figure 4. Initial screen on page load

Chapter 3. Starting with htmx | 21


Modern frontends with htmx

If you click on the button, the text will change to "Hello htmx!".

Figure 5. Screen after click on 'Click me'

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.

Figure 6. DevTools showing the htmx request

22 | Chapter 3. Starting with htmx


Modern frontends with htmx

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>

By default, AJAX requests are triggered by the “natural” event of an element:

• input, textarea & select are triggered on the change event

• form is triggered on the submit event

• everything else is triggered by the click event

If we want to have a different trigger, we can use the hx-trigger attribute to configure this.

3.2.1. Trigger modifiers


An example of this is when you have an input field that should initiate a request each time the user
presses a key. This request should update the input’s value and include a 500-millisecond delay to
prevent sending too many requests when the user is still typing. We can achieve this with the
following HTML:

<input type="text" name="q"


th:hx-get="@{/htmx}"
hx-target="#message"
hx-trigger="keyup changed delay:500ms">

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.

3.2.2. Trigger filters

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

Chapter 3. Starting with htmx | 23


Modern frontends with htmx

combination:

<div class="bg-gray-200 p-2"


th:hx-get="@{/htmx}"
hx-target="this"
hx-trigger="keyup[shiftKey&&key=='K'] from:body">
Press SHIFT-K to have the div replaced by the htmx response.
</div>

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.

3.2.3. Special events


There are also a few special events that htmx supports.

A first example is the load event that triggers the request when the page is loaded:

<div class="bg-gray-200 p-2"


th:hx-get="@{/htmx}"
hx-target="this"
hx-trigger="load">
I am a div that will be replaced on page load.
</div>

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:

<div class="bg-gray-200 p-2"


th:hx-get="@{/htmx}"
hx-target="this"
hx-trigger="load delay:2s">
I am a div that will be replaced on page load.
</div>

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

24 | Chapter 3. Starting with htmx


Modern frontends with htmx

actually view the content, you can use the revealed event trigger.

<div class="bg-gray-200 p-2"


th:hx-get="@{/htmx}"
hx-target="this"
hx-trigger="revealed">
I am a div that is replaced when scrolling into view.
</div>

This event is fired once when an element first scrolls into the viewport.

A final option for hx-trigger is the use of polling via every:

<div class="bg-gray-200 p-2"


th:hx-get="@{/htmx}"
hx-target="this"
hx-trigger="every 1s">
I am a div that is replaced every second.
</div>

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>

Chapter 3. Starting with htmx | 25


Modern frontends with htmx

<button class="items-center justify-center px-6 py-2.5 text-


center text-white duration-200 bg-black border-2 border-black rounded-
full inline-flex hover:bg-transparent hover:border-black hover:text-
black focus:outline-none focus-visible:outline-black text-sm focus-
visible:ring-black"
th:hx-get="@{/htmx}"
hx-target="#message">
Click me
</button>
</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

26 | Chapter 3. Starting with htmx


Modern frontends with htmx

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>

<h2 class="text-xl mb-2">Trigger Modifiers</h2>


<div class="mb-4">
<div class="text-sm">This has <code class="font-mono">hx-
trigger="keyup changed delay:500ms"</code></div>
<input type="text" name="q"
th:hx-get="@{/htmx}"
hx-target="#message"
hx-trigger="keyup changed delay:500ms">
</div>
<div class="mb-4">
<div class="text-sm">This has <code class="font-mono">hx-
trigger="keyup[shiftKey&&key=='K'] from:body"</code>.
</div>
<div class="bg-gray-200 p-2"
th:hx-get="@{/htmx}"

Chapter 3. Starting with htmx | 27


Modern frontends with htmx

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>

<h2 class="text-xl mb-2">Trigger filters</h2>


<div class="mb-4">
<div class="text-sm">This has <code class="font-mono">hx-
trigger="click[shiftKey]"</code></div>
<div class="bg-gray-200 p-2 cursor-pointer"
th:hx-get="@{/htmx}"
hx-target="#message"
hx-trigger="click[shiftKey]">
I am a div you can click on to trigger a htmx request if you
have SHIFT pressed.
</div>
</div>

<h2 class="text-xl mb-2">Special events</h2>


<div class="mb-4">
<div class="text-sm">The div below is replaced 3 seconds after
the page is loaded</div>
<div class="bg-gray-200 p-2"
th:hx-get="@{/htmx}"
hx-target="this"
hx-trigger="load delay:2s">
I am a div that will be replaced on page load.
</div>
</div>
<div class="mb-4">
<div class="text-sm">The div below is replaced every
second</div>
<div class="bg-gray-200 p-2"
th:hx-get="@{/htmx}"
hx-target="this"
hx-trigger="every 1s">
I am a div that is replaced every second.
</div>
</div>
<div class="mb-4">
<div class="text-sm">The div below is only loaded when scrolling
into view</div>
<div class="bg-gray-200 p-2"
th:hx-get="@{/htmx?delay=4}"

28 | Chapter 3. Starting with htmx


Modern frontends with htmx

hx-on="htmx:beforeRequest: this.innerHTML = 'Starting


request...'"
hx-target="this"
hx-trigger="revealed">
I am a div that is replaced when scrolling into view.
</div>
</div>
</div>
</body>
</html>

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

Update tailwind.config.js to include the plugin:

/** @type {import('tailwindcss').Config} */


module.exports = {
content: ['./src/main/resources/templates/**/*.html'],
theme: {
extend: {},
},
plugins: [
require('@tailwindcss/forms') ①
],
}

① Add the Tailwind CSS forms plugin to the plugins section.

Also replace the htmx.html page with this:

<!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.

Chapter 3. Starting with htmx | 29


Modern frontends with htmx

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";
}

① Add the currentTime attribute to the model.

After these changes, restart the Spring Boot application and run npm run build && npm run
watch to make sure everything is up-to-date.

30 | Chapter 3. Starting with htmx


Modern frontends with htmx

Figure 7. Trigger demo application

Chapter 3. Starting with htmx | 31


Modern frontends with htmx

Figure 8. Trigger demo application after triggering an htmx request

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>

32 | Chapter 3. Starting with htmx


Modern frontends with htmx

</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.

The following options are available:

In each of the examples, it is assumed that the server returns this HTML snippet:

 <div>This is the server response</div>

• 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>

Then the resulting HTML will be:

<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

Chapter 3. Starting with htmx | 33


Modern frontends with htmx

Starting from:

<div hx-get="/"
hx-target="this"
hx-swap="outerHTML">
<div>Hello htmx</div>
</div>

Then the resulting HTML will be:

<div>This is the server response</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>

Then the resulting HTML will be:

<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>

34 | Chapter 3. Starting with htmx


Modern frontends with htmx

• 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>

Then the resulting HTML will be:

<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>

Chapter 3. Starting with htmx | 35


Modern frontends with htmx

</div>
</div>

Then the resulting HTML will be:

<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>

Then the resulting HTML will be:

<div hx-get="/"
hx-target="#list"
hx-swap="afterend">
<div>Hello htmx</div>
</div>
<div id="parent">
<div id="list">
<div>Child 1</div>

36 | Chapter 3. Starting with htmx


Modern frontends with htmx

<div>Child 2</div>
</div>
<div>This is the server response</div>
</div>

• delete: Deletes the target element, regardless of the response

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>

Then the resulting HTML will be:

<div hx-get="/"
hx-target="#list"
hx-swap="delete">
<div>Hello htmx</div>
</div>
<div id="parent">
</div>

• none: Nothing is swapped.

3.5. HTTP verbs


In standard HTML, you can only send GET or POST requests to the server. If you use JavaScript, you
can use all the available HTTP verbs like PUT, PATCH, DELETE, … as well. With htmx, you can also issue
such requests with the attributes that the library has defined:

• hx-get: Issues a GET request to the given URL

• hx-post: Issues a POST request to the given URL

• hx-put: Issues a PUT request to the given URL

• hx-patch: Issues a PATCH request to the given URL

Chapter 3. Starting with htmx | 37


Modern frontends with htmx

• hx-delete: Issues a DELETE request to the given URL

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.

38 | Chapter 3. Starting with htmx


Modern frontends with htmx

Chapter 4. Request and response headers


At its core, htmx is a JavaScript library that extends HTML and uses HTTP to communicate with your
backend of choice. In our case, this is a Spring Boot with Thymeleaf backend, but it can really be
anything.

The library uses some HTTP request and response headers which can be used to build some more
advanced functionality.

4.1. Request headers


HX-Boosted
Indicates that the request is made by htmx via an HTML element that has hx-boost enabled.

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">

then the headers will be like this:

Chapter 4. Request and response headers | 39


Modern frontends with htmx

Figure 9. Htmx request headers

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:

@GetMapping(value = "/active-items-count", headers = "HX-Request") ①


public String htmxActiveItemsCount(Model model) {
model.addAttribute("numberOfActiveItems", getNumberOfActiveItems());

return "fragments :: active-items-count";


}

① 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:

@GetMapping(value = "/", headers = "HX-Request")


public String htmxMethod(Model model,
@RequestHeader("HX-Trigger") String elementId)
{ ①
// Do something with the id of the HTML element that called into
this endpoint
}

① Use @RequestHeader to inject the value of htmx specific request headers.

40 | Chapter 4. Request and response headers


Modern frontends with htmx

4.2. Response headers


The request headers we just learned about allow the backend to react to what the HTML is asking. We
can use response headers to influence what the browser does from the backend. These are the
response headers that htmx supports:

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:

@PostMapping(value = "/", headers = "HX-Request")


public String htmxAddMethod(Model model,
HttpServletResponse response) { ①

// ...

response.setHeader("HX-Trigger", "itemAdded"); ②

Chapter 4. Request and response headers | 41


Modern frontends with htmx

// ...
}

① Inject HttpServletResponse to be able to set the response headers.

② Set the HX-Trigger response header.

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:

<div hx-trigger="itemAdded from:body" hx-get="/item-count"></div>

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.

4.3. Htmx-spring-boot library


Working with those request and response headers in Spring is not terribly difficult, but it is a bit error-
prone since you might mistype the name of a header. You could create constants to make this better,
but there is a library called htmx-spring-boot that can make it even easier. It also has some other nice
features that we will discuss later.

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.

If you are using ttcli to generate your project, it is added by default.

42 | Chapter 4. Request and response headers


Modern frontends with htmx

By using this libary, we can use the @HxRequest annotation to indicate that only htmx should call the
endpoint.

The example:

@GetMapping(value = "/active-items-count", headers = "HX-Request")


public String htmxActiveItemsCount(Model model) {
model.addAttribute("numberOfActiveItems", getNumberOfActiveItems());

return "fragments :: active-items-count";


}

becomes:

@GetMapping("/active-items-count")
@HxRequest
public String htmxActiveItemsCount(Model model) {
model.addAttribute("numberOfActiveItems", getNumberOfActiveItems());

return "fragments :: active-items-count";


}

A little nicer and fully type-safe.

To inspect other request headers, you can inject an HtmxRequest instance:

@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:

@PostMapping(value = "/", headers = "HX-Request")


public String htmxAddMethod(Model model,
HttpServletResponse response) {

Chapter 4. Request and response headers | 43


Modern frontends with htmx

// ...

response.setHeader("HX-Trigger", "itemAdded");

// ...
}

We can now re-write it like this:

@PostMapping("/")
@HxRequest
@HxTrigger("itemAdded")
public String htmxAddMethod(Model model) {

// ...
}

The library also includes a custom Thymeleaf processor to make the templates a bit nicer.

Without the library, you need to use something like this:

<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.

44 | Chapter 4. Request and response headers


Modern frontends with htmx

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.

Chapter 4. Request and response headers | 45


Modern frontends with htmx

Chapter 5. Project 1: TodoMVC


This chapter will show how to implement the TodoMVC application with Spring Boot, Thymeleaf and
htmx.

5.1. Initial implementation


We will start with a "traditional" Thymeleaf implementation of the project and add htmx in various
ways to add some nice interactivity.

Create a new project using ttcli:

• Group: com.modernfrontendshtmx

• Artifact: todomvc

• Project Name: TodoMVC

• Spring Boot version: 3.1.4

• Live reload: NPM based

• Web dependencies: htmx

To keep things simple on the backend, we will use an h2 in-memory database and Spring Data JPA to
persist the todo items.

Start by adding the following dependencies to the pom.xml:

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>

<!-- Web dependencies -->


<dependency>

46 | Chapter 5. Project 1: TodoMVC


Modern frontends with htmx

<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>
...

Create a todo package and add TodoItem, TodoItemNotFoundException, TodoItemRepository


and SpringDataJpaTodoItemRepository classes:

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;

private boolean completed;

protected TodoItem() {

public TodoItem(String title,


boolean completed) {
this.title = title;
this.completed = completed;
}

Chapter 5. Project 1: TodoMVC | 47


Modern frontends with htmx

public Long getId() {


return id;
}

public String getTitle() {


return title;
}

public void setTitle(String title) {


this.title = title;
}

public boolean isCompleted() {


return completed;
}

public void setCompleted(boolean completed) {


this.completed = completed;
}
}

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;

48 | Chapter 5. Project 1: TodoMVC


Modern frontends with htmx

@Repository
public class TodoItemRepository {
private final SpringDataJpaTodoItemRepository repository;

public TodoItemRepository(SpringDataJpaTodoItemRepository
repository) {
this.repository = repository;
}

public void save(TodoItem todoItem) {


repository.save(todoItem);
}

public Optional<TodoItem> findById(Long id) {


return repository.findById(id);
}

public List<TodoItem> findAll() {


return repository.findAll();
}

public void deleteById(Long id) {


repository.deleteById(id);
}

public long count() {


return repository.count();
}

public int countAllByCompleted(boolean completed) {


return repository.countAllByCompleted(completed);
}

public List<TodoItem> findAllByCompleted(boolean completed) {


return repository.findAllByCompleted(completed);
}
}

com.modernfrontendshtmx.todomvc.todo.SpringDataJpaTodoItemRepository

package com.modernfrontendshtmx.todomvc.todo;

import org.springframework.data.repository.ListCrudRepository;

import java.util.List;

Chapter 5. Project 1: TodoMVC | 49


Modern frontends with htmx

public interface SpringDataJpaTodoItemRepository extends


ListCrudRepository<TodoItem, Long> {
int countAllByCompleted(boolean completed);

List<TodoItem> findAllByCompleted(boolean completed);


}

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 {

private final TodoItemRepository repository;

public TodoItemController(TodoItemRepository repository) {


this.repository = repository;
}

@GetMapping
public String index(Model model) {
addAttributesForIndex(model, ListFilter.ALL);
return "index";
}

@GetMapping("/active")
public String indexActive(Model model) {
addAttributesForIndex(model, ListFilter.ACTIVE);

50 | Chapter 5. Project 1: TodoMVC


Modern frontends with htmx

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")

Chapter 5. Project 1: TodoMVC | 51


Modern frontends with htmx

public String deleteCompletedItems() {


List<TodoItem> items = repository.findAllByCompleted(true);
for (TodoItem item : items) {
repository.deleteById(item.getId());
}
return "redirect:/";
}

private void addAttributesForIndex(Model model,


ListFilter listFilter) {
model.addAttribute("item", new TodoItemFormData());
model.addAttribute("filter", listFilter);
model.addAttribute("todos", getTodoItems(listFilter));
model.addAttribute("totalNumberOfItems", repository.count());
model.addAttribute("numberOfActiveItems",
getNumberOfActiveItems());
model.addAttribute("numberOfCompletedItems",
getNumberOfCompletedItems());
}

private List<TodoItemDto> getTodoItems(ListFilter filter) {


return switch (filter) {
case ALL -> convertToDto(repository.findAll());
case ACTIVE -> convertToDto(repository.findAllByCompleted
(false));
case COMPLETED -> convertToDto(repository.
findAllByCompleted(true));
};
}

private List<TodoItemDto> convertToDto(List<TodoItem> todoItems) {


return todoItems
.stream()
.map(todoItem -> new TodoItemDto(todoItem.getId(),
todoItem.getTitle(),
todoItem.isCompleted()))
.collect(Collectors.toList());
}

private int getNumberOfActiveItems() {


return repository.countAllByCompleted(false);
}

private int getNumberOfCompletedItems() {


return repository.countAllByCompleted(true);

52 | Chapter 5. Project 1: TodoMVC


Modern frontends with htmx

public record TodoItemDto(long id, String title, boolean completed)


{
}

public enum ListFilter {


ALL,
ACTIVE,
COMPLETED
}
}

com.modernfrontendshtmx.todomvc.todo.web.TodoItemFormData

package com.modernfrontendshtmx.todomvc.todo.web;

import jakarta.validation.constraints.NotBlank;

public class TodoItemFormData {


@NotBlank
private String title;

public String getTitle() {


return title;
}

public void setTitle(String title) {


this.title = title;
}
}

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-

Chapter 5. Project 1: TodoMVC | 53


Modern frontends with htmx

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">

54 | Chapter 5. Project 1: TodoMVC


Modern frontends with htmx

</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>

Chapter 5. Project 1: TodoMVC | 55


Modern frontends with htmx

<p>Part of <a href="http://todomvc.com">TodoMVC</a></p>


</footer>
<!-- Scripts here. Don't remove ↓ -->
<script th:src="@{/webjars/todomvc-common/base.js}"></script>
</body>
</html>

A todo item itself is rendered via a fragment in fragments.html:

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>

Update application.properties to allow using PUT and DELETE requests:

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.

Now run npm run watch to open the application.

56 | Chapter 5. Project 1: TodoMVC


Modern frontends with htmx

The application should look like this if all goes well:

Figure 10. TodoMVC start screen

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:

Chapter 5. Project 1: TodoMVC | 57


Modern frontends with htmx

Figure 11. TodoMVC after adding 2 todo’s and completing one

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.

Let’s fix this by adding htmx to the mix!

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.

5.2. Boost the application


Because we used ttcli to generate the project, we have htmx as a webjar dependency already.
However, by copying over the index.html, we are no longer importing it into our HTML page.

To fix this, add the following to index.html (just before the closing of the <body> tag):

<script type="text/javascript"

58 | Chapter 5. Project 1: TodoMVC


Modern frontends with htmx

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

Now that we have htmx, we can start using it.

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.

The only thing we need to do is replace this:

<section class="todoapp">

with:

<section class="todoapp" hx-boost="true">

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}">

Otherwise, htmx can’t intercept the form submit.

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.

5.3. Fine-grained htmx implementation

Chapter 5. Project 1: TodoMVC | 59


Modern frontends with htmx

5.3.1. Add a new todo item


In this section, we will rework the TodoMVC application to add htmx in a more fine-grained way. We
will only send back the HTML that actually should change instead of sending the complete page each
time like we have with hx-boost.

Start by removing the hx-boost="true" from index.html.

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:

<form th:action="@{/}" method="post" th:object="${item}">


<input class="new-todo" placeholder="What needs to be done?"
autofocus
th:field="*{title}">
</form>

We will change the HTML to add some htmx attributes on the <input> field:

<form id="new-todo-form" th:action="@{/}" method="post"


th:object="${item}">
<input id="new-todo-input" class="new-todo" placeholder="What needs
to be done?" autofocus
autocomplete="false"
name="title"
th:field="*{title}"
hx-target="#todo-list"
hx-swap="beforeend"
hx-post="/"
hx-trigger="keyup[key=='Enter']"
>
</form>

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-post: htmx will do a POST request to /

• hx-target: The HTML response of the POST request should be added to the HTML element with

60 | Chapter 5. Project 1: TodoMVC


Modern frontends with htmx

id todo-list that is present on the page.


• hx-swap: The HTML response must be added just before the end of the HTML element that is
targeted.

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)); ③

return "fragments :: todoItem"; ④


}

private TodoItemDto toDto(TodoItem todoItem) {


return new TodoItemDto(todoItem.getId(),
todoItem.getTitle(),
todoItem.isCompleted());
}

① 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.

② Do the actual work of saving the todo item in the database.


③ Add the item converted to the DTO in the model so Thymeleaf can use it to render the template.
④ Ask Thymeleaf to render the todoItem fragment from fragments.html.

To support the controller method, update the save method of TodoItemRepository to return the
saved TodoItem:

public TodoItem save(TodoItem todoItem) {


return repository.save(todoItem);
}

Chapter 5. Project 1: TodoMVC | 61


Modern frontends with htmx

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:

<ul id="todo-list" class="todo-list">


<li th:insert="~{fragments :: todoItem(${item})}" th:each="item :
${todos}" th:remove="tag">
</li>
</ul>

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.

Supporting this kind of behaviour is called Progressive Enhancement. It can be a


good design goal if you aim to reach a broad audience. However, be aware that
 achieving this goal will require effort to ensure that everything functions correctly
both with JavaScript enabled and disabled.

2. It is also possible to add the hx-… attributes on the <form> itself like this:

<form id="new-todo-form" th:action="@{/}" method="post"


th:object="${item}"

62 | Chapter 5. Project 1: TodoMVC


Modern frontends with htmx

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:

<section class="main" th:if="${totalNumberOfItems > 0}">

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.

Change the section to:

<section id="main-section" class="main"


th:classappend="${totalNumberOfItems == 0?'hidden':''}">

Also change the footer to:

<footer id="main-footer" class="footer"


th:classappend="${totalNumberOfItems == 0?'hidden':''}">

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.

Chapter 5. Project 1: TodoMVC | 63


Modern frontends with htmx

② 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.

Again, we need a bit of JavaScript to make this work:

<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.

5.3.2. Update number of items


We can now add items in our todo list via htmx, without any page refresh, but the number of active
items in the footer does not get updated.

To make this work again, we can use events in htmx.

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.

64 | Chapter 5. Project 1: TodoMVC


Modern frontends with htmx

Client Server

todo-list active-items-count Spring Boot App

1 POST / (via htmx)

2 HTML snippet with new todo item

3 Send `itemAdded` event

4 GET /active-items-count

HTML snippet with


5
active items count

todo-list active-items-count Spring Boot App

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}">

Chapter 5. Project 1: TodoMVC | 65


Modern frontends with htmx

<span class="todo-count"><strong>1</strong> item left</span>


</th:block>
</span>

Note the added htmx attributes:

• hx:get: instruct htmx to do an HTTP GET on /active-items-count. We are using hx:get


instead of hx-get to have Thymeleaf processing enabled.
• hx-swap: instruct htmx to replace the complete span with what we get back from the GET
request.
• hx-trigger: trigger the HTTP GET when there is an event itemAdded coming from any element
that is a child element of <body>.

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.

Use it like this in the index.html:

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));

return "fragments :: todoItem";


}

① Use @HxTrigger to send back itemAdded as the value of the HX-Trigger response header.

66 | Chapter 5. Project 1: TodoMVC


Modern frontends with htmx

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.

We need to add the implementation for that request to TodoItemController:

@GetMapping("/active-items-count")
@HxRequest
public String htmxActiveItemsCount(Model model) {
model.addAttribute("numberOfActiveItems", getNumberOfActiveItems());

return "fragments :: active-items-count";


}

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.

5.3.3. Mark item as completed


We can continue to make our application more interactive (less page reloads) by implementing
toggling the completion state of an item with htmx.

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)); ③

return "fragments :: todoItem"; ④


}

① The @HxRequest annotation ensures this method is only called for requests done by htmx.

② Send an HX-Trigger: itemCompletionToggled response header back so that other parts of


the page can react to the toggling of the item. In this case, we will update the label that shows the
number of active items.

Chapter 5. Project 1: TodoMVC | 67


Modern frontends with 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.

On the HTML side, we will replace this:

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>

68 | Chapter 5. Project 1: TodoMVC


Modern frontends with htmx

</li>

These are the changes in detail:

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.

We currently only listen to the itemAdded event:

...
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"

5.3.4. Delete item


We’ll finish this fine-grained htmx implementation of TodoMVC with implementing the delete of todo
items.

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") ②

Chapter 5. Project 1: TodoMVC | 69


Modern frontends with htmx

public String htmxDeleteTodoItem(@PathVariable("id") Long id) {


repository.deleteById(id);

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).

On the HTML side, we will replace this:

src/main/resources/templates/fragments.html

<form th:action="@{/{id}(id=${item.id})}" th:method="delete">


<button class="destroy"></button>
</form>

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.

70 | Chapter 5. Project 1: TodoMVC


Modern frontends with htmx

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.

• Use the outerHTML swap implementation by setting hx-swap.

Chapter 5. Project 1: TodoMVC | 71


Modern frontends with htmx

Chapter 6. Out of Band Swaps

6.1. General principles


In our TodoMVC project, we used events to trigger additonal requests. When the itemAdded event
was triggered, for example, the part of the UI that shows the total count listened to that event. It in
turn did a new request to the server to get the updated total count and swapped in the new html for
the count status.

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.

As an example, the returned HTML might look like this:

<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.

This is how the application will look like:

72 | Chapter 6. Out of Band Swaps


Modern frontends with htmx

Figure 12. Timesheets application

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.

Both updates will be delivered as out of band swaps to the browser.

6.2.1. Project generation

Run ttcli init to create a new project.

• Group: com.modernfrontendshtmx

• Artifact: oob-timesheets

• Project name: OOB Timesheets

• Spring Boot: 3.1.4

• Live reload: npm-based-with-tailwind

• Web dependencies: htmx

• Tailwind dependencies: forms

Chapter 6. Out of Band Swaps | 73


Modern frontends with htmx

6.2.2. Domain model

We start by implementing a small domain model existing of Project and TimeRegistration


classes.

Project is a straightforward record with just an id and a name.

com.modernfrontendshtmx.oobtimesheets.project.Project

package com.modernfrontendshtmx.oobtimesheets.project;

public record Project(int id, String name) {


}

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;

public class TimeRegistration {


private int projectId;
private LocalDate date;
private Duration duration;

public TimeRegistration(int projectId,


LocalDate date,
Duration duration) {
this.projectId = projectId;
this.date = date;
this.duration = duration;
}

public int getProjectId() {


return projectId;
}

public LocalDate getDate() {


return date;
}

public Duration getDuration() {

74 | Chapter 6. Out of Band Swaps


Modern frontends with htmx

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"),

Chapter 6. Out of Band Swaps | 75


Modern frontends with htmx

new Project(3, "SynthoGuard"))


.collect(Collectors.toMap(Project::id,
Function.identity())));
}

public List<Project> getProjects() {


return projects.values().stream()
.sorted(Comparator.comparing(Project::id))
.toList();
}
}

We also need a service to keep track of the time registrations. It has 2 methods:

• addOrUpdateRegistration(): Store a new time entry, or update an existing one.

• 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<>();

public void addOrUpdateRegistration(int projectId,


LocalDate date,
Duration duration) {
registrations.put(new ProjectDate(projectId, date), duration);
}

public Duration getTotal(Set<Integer> projectIds,


Set<LocalDate> dates) {
return registrations
.entrySet()
.stream()

76 | Chapter 6. Out of Band Swaps


Modern frontends with htmx

.filter(entry -> projectIds.contains(entry.getKey


().projectId())
&& dates.contains(entry.getKey().
date()))
.reduce(Duration.ZERO,
(duration, entry) -> duration.plus(entry
.getValue()),
Duration::plus);
}

private record ProjectDate(int projectId,


LocalDate date) {
}
}

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 {

Chapter 6. Out of Band Swaps | 77


Modern frontends with htmx

private final ProjectService projectService;


private final TimeRegistrationService timeRegistrationService;

public HomeController(ProjectService projectService,


TimeRegistrationService
timeRegistrationService) {
this.projectService = projectService;
this.timeRegistrationService = timeRegistrationService;
}

@GetMapping
public String index(Model model,
Locale locale) { ①
model.addAttribute("projects", projectService.getProjects()); ②
List<LocalDate> daysOfCurrentWeek = getDaysOfCurrentWeek(
locale);
model.addAttribute("days", daysOfCurrentWeek); ③
return "index";
}

private static List<LocalDate> getDaysOfCurrentWeek(Locale locale) {



LocalDate now = LocalDate.now();
DayOfWeek firstDayOfWeek = WeekFields.of(locale
).getFirstDayOfWeek();
LocalDate firstDay = now.with(firstDayOfWeek);
return Stream.iterate(firstDay, date -> date.plusDays(1))
.limit(7)
.toList();
}
}

① Inject Locale so we can use it to know what the first day of the week is for the user.

② Add projects to the model with the list of projects.

③ Add days to the model containing the 7 days of the current week.

④ Helper method to calculate the 7 days of the current week.

The UI itself is implemented in the index.html Thymeleaf template.

index.html

<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"

78 | Chapter 6. Out of Band Swaps


Modern frontends with htmx

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.

Chapter 6. Out of Band Swaps | 79


Modern frontends with htmx

⑤ 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.

⑦ Loop over the days to generate an input for each day.


⑧ Add an <input> to allow the user to enter a duration.

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.

The application should look something like this:

Figure 13. Application showing 3 projects and the dates of the current week

6.2.4. Input of time registration durations


With the UI in place, it is time to make it interactive. We want to send a request to the Spring Boot
application when the user enters a number in any of the text inputs. The trigger we use is keyup
changed delay:500ms, which is whenever a key is pressed and the value actually changed, with a
debounce value of 500 milliseconds. The request we will do is a PUT request using the id of the project
and the date as URL path variables. The input has a name of value, so the actual value will be sent
also along in that request.

This is how the <input> in index.html looks like with this change:

80 | Chapter 6. Out of Band Swaps


Modern frontends with htmx

index.html

<input type="text" class="w-full"


name="value"

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>

① Use th:text to display the value of the total model attribute.

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.

We’ll start by adding total 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";
}

private Duration getTotal(List<LocalDate> daysOfCurrentWeek) {


Set<Integer> projectIds = projectService.getProjects()
.stream()
.map(Project::id)
.collect(Collectors.toSet());
return timeRegistrationService.getTotal(projectIds,

Chapter 6. Out of Band Swaps | 81


Modern frontends with htmx

Set.copyOf(daysOfCurrentWeek));
}

Next, we add a new method to HomeController for the PUT request:

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)));

return "index :: #overall-total"; ⑦


}

① This controller method should only be used for htmx requests.


② URL to use with the 2 path variables.
③ The value of the input will be injected in this value parameter.

④ 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.

⑤ Store the duration for the given project and date.


⑥ Calculate the total after the update and put it into the Model.
⑦ Return the <div> that has the total value to just render that part of index.html. We use CSS
syntax to refer to the overall-total HTML id.

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>

So why don’t we see anything happening?

82 | Chapter 6. Out of Band Swaps


Modern frontends with htmx

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>

① Add hx-swap-oob="true" to support out-of-band swaps.

Test again, and the total should now properly update as soon as you enter something in any of the
input fields.

Chapter 6. Out of Band Swaps | 83


Modern frontends with htmx

Figure 14. Automatic update of total when values are entered

6.2.5. Day totals


Let’s implement an additional requirement in our little example. Below each day, we want to show the
day totals. When the user enters a new time registration, the total of that day and the global total
should both be updated.

Add an extra row at the bottom:

index.html

<div class="grid grid-cols-9 mb-2 gap-x-2">


<div class="col-span-2"></div>
<div th:each="day : ${days}">
<div class="flex justify-center">
<div th:fragment="day-total"
hx-swap-oob="true"
th:id="${'dayTotal_' + #temporals.format(day,
'yyyyMMdd')}"
th:text="${__${'dayTotal_' + #temporals.format(day,
'yyyyMMdd')}__}">0</div> ①
</div>
</div>

84 | Chapter 6. Out of Band Swaps


Modern frontends with htmx

</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";
}

① Build the attribute name.


② Calculate the total for the day.

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

Chapter 6. Out of Band Swaps | 85


Modern frontends with htmx

.ofMinutes((long) (value * 60.0));


timeRegistrationService.addOrUpdateRegistration(projectId, date,
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.

86 | Chapter 6. Out of Band Swaps


Modern frontends with htmx

Figure 15. Day totals and overall total updating automatically

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.

Chapter 6. Out of Band Swaps | 87


Modern frontends with htmx

Chapter 7. Client-side scripting


Just because this book (and htmx in general) advocates for using less JavaScript, it does not mean we
are against using JavaScript. With htmx, you can code your application so that it also works without
JavaScript. But if you don’t want to do that extra effort for progressive enhancement, because you
know your users, that is fine too.

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.

7.1. Vanilla JavaScript


The basic idea of htmx is sending a request, getting an HTML response back and swapping this
response into the DOM. All of this assumes there is no problem sending the request or receiving the
response. This is directly opposite of the first fallacy of distributed computing: "The network is
reliable".

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/.

The final application will look like this:

88 | Chapter 7. Client-side scripting


Modern frontends with htmx

Figure 16. Final application to showcase error handling

7.1.1. Project setup

Run ttcli init to create a new project. Instead of Tailwind, let’s use Bootstrap this time.

• Group: com.modernfrontendshtmx

• Artifact: error-handling

• Project name: Error Handling

• Spring Boot: 3.1.4

• Live reload: npm-based

• Web dependencies: bootstrap, htmx

7.1.2. Numbers Api


The API of numbersapi.com is quite easy. We can do a GET request to https://numbersapi.com/
{number} and we get a String back with a random fact about the number in the path variable. We can
use the Spring 6 HTTP Interface Client to code calling the API.

To use that, we need to add the Webflux dependency in our project:

Chapter 7. Client-side scripting | 89


Modern frontends with htmx

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;

public interface NumbersApi {


@GetExchange("/{number}") ①
String getFact(@PathVariable("number") int number); ②
}

① Use GetExchange to indicate a GET request should be done.

② 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() { ①

90 | Chapter 7. Client-side scripting


Modern frontends with htmx

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();

private final NumbersApi api;

Chapter 7. Client-side scripting | 91


Modern frontends with htmx

public NumbersApiGateway(NumbersApi api) {


this.api = api;
}

public String getFact(int number) {


int randomNumber = RANDOM_GENERATOR.nextInt(3); ①
switch (randomNumber) {
case 0 -> { ②
return api.getFact(number);
}
case 1 -> { ③
try {
Thread.sleep(5000);
return api.getFact(number);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException(e);
}
}

default -> throw new RuntimeException("Unable to get a fact
about the number " + number);
}
}
}

① Generate a random number to decide what behavior we want to have.


② If the number is 0, just call the remote service.

③ 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>

92 | Chapter 7. Client-side scripting


Modern frontends with htmx

<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> ③
</form>
<div id="result" class="mt-4"></div> ④
</div>
</body>
</html>

① A form that does a GET request to /number-facts. The response HTML is swapped into the
innerHTML of the #result div.

② The input where the user can type in a number.


③ Button to submit the form.
④ The div where the response is put when it returns from the server.

Update the HomeController to expose the /number-facts endpoint for htmx:

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;

public HomeController(NumbersApiGateway numbersApiGateway) { ①


this.numbersApiGateway = numbersApiGateway;
}

@GetMapping
public String index(Model model) {
return "index";

Chapter 7. Client-side scripting | 93


Modern frontends with htmx

@HxRequest
@GetMapping("/number-facts")
public String getRandomNumberFact(Model model,
Integer number) { ②
model.addAttribute("fact", numbersApiGateway.getFact(number));

return "fragments :: result"; ④
}
}

① Inject the NumbersApiGateway into the controller.

② 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.

④ Render the result fragment.

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.

7.1.4. Progress indicator


Before we start on actual error handling, we will add a progress indicator while the request is in-flight.

94 | Chapter 7. Client-side scripting


Modern frontends with htmx

Boostrap has a border spinner we can use for this.

Update index.html to add the border spinner:

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.

Chapter 7. Client-side scripting | 95


Modern frontends with htmx

Figure 17. Progress indicator showing while the request is in-flight

7.1.5. Error handling


The htmx library has a lot of events that it sends for various reasons. We can use some of those
events to our advantage to handle errors in a nice way.

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">

96 | Chapter 7. Client-side scripting


Modern frontends with htmx

<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> ①

<template th:replace="~{fragments :: error-message}"></template> ②


<template th:replace="~{fragments :: timeout-message}"></template>
</div>
<th:block layout:fragment="script-content">
<script>
document.addEventListener('htmx:beforeRequest', ev => { ③
removeErrorMessage();
});
document.addEventListener('htmx:sendError', ev => { ④
showErrorMessage('htmx-request-error');
});
document.addEventListener('htmx:responseError', ev => { ⑤
showErrorMessage('htmx-request-error');
});
document.addEventListener('htmx:timeout', ev => { ⑥
showErrorMessage('htmx-timeout');
});

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 = '';
}

Chapter 7. Client-side scripting | 97


Modern frontends with htmx

</script>
</th:block>
</body>
</html>

① Add a div that will serve as the parent for the error message.

② Include the error and timeout message templates.


③ The htmx:beforeRequest event is fired just before htmx sends out the request. We clear any
previous error message that might be in place at that point.
④ Listen for htmx:sendError and show a 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.

For the htmx:timeout event to work, we need to configure a timeout on the


request. By default, there is no timeout configured.

There are 2 ways to do this:

• Globally via the htmx configuration:

 <meta name="htmx-config" content='{"timeout": 30000}'>

This configures a global timeout of 30000 milliseconds (30 seconds).

• 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>

98 | Chapter 7. Client-side scripting


Modern frontends with htmx

① Set timeout of 1 second.

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

Chapter 7. Client-side scripting | 99


Modern frontends with htmx

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.

100 | Chapter 7. Client-side scripting


Modern frontends with htmx

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.

From the Alpine website:

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.

The simplest example that shows Alpine in action is a counter:

<div x-data="{ count: 0 }"> ①


<button x-on:click="count++">Increment</button> ②

<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.

Chapter 7. Client-side scripting | 101


Modern frontends with htmx

② 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.

The final application will look like this:

Figure 19. Final application to showcase inline editing

7.2.1. Project setup

Run ttcli init to create a new project.

• Group: com.modernfrontendshtmx

• Artifact: inline-editing

• Project name: Inline editing

• Spring Boot: 3.1.4

102 | Chapter 7. Client-side scripting


Modern frontends with htmx

• Live reload: npm-based-with-tailwind

• Web dependencies: alpinejs, htmx

7.2.2. Domain model


We will create the following classes to model our issue tracking domain:

• Issue

• IssuePriority

• IssueType

• Status

• SubTask

Listings for these classes:

package com.modernfrontendshtmx.inlineediting.issue;

import java.util.List;

public class Issue {


private String key;
private String summary;
private IssueType type;
private IssuePriority priority;
private String fixVersion;
private List<SubTask> subTasks;

public Issue(String key,


String summary,
IssueType type,
IssuePriority priority,
String fixVersion,
List<SubTask> subTasks) {
this.key = key;
this.summary = summary;
this.type = type;
this.priority = priority;
this.fixVersion = fixVersion;
this.subTasks = subTasks;
}

public String getKey() {


return key;
}

Chapter 7. Client-side scripting | 103


Modern frontends with htmx

public String getSummary() {


return summary;
}

public void setSummary(String summary) {


this.summary = summary;
}

public IssueType getType() {


return type;
}

public IssuePriority getPriority() {


return priority;
}

public String getFixVersion() {


return fixVersion;
}

public List<SubTask> getSubTasks() {


return subTasks;
}

public void setSubTasks(List<SubTask> subTasks) {


this.subTasks = subTasks;
}
}

package com.modernfrontendshtmx.inlineediting.issue;

public enum IssuePriority {


LOW,
MEDIUM,
HIGH
}

package com.modernfrontendshtmx.inlineediting.issue;

public enum IssueType {


STORY,
BUG,

104 | Chapter 7. Client-side scripting


Modern frontends with htmx

TASK
}

package com.modernfrontendshtmx.inlineediting.issue;

public enum Status {


TODO,
IN_PROGRESS,
DONE
}

package com.modernfrontendshtmx.inlineediting.issue;

public record SubTask(String key, String summary, Status status) {


}

Next, create an IssueRepository backed by a HashMap to keep things simple:

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(

Chapter 7. Client-side scripting | 105


Modern frontends with htmx

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())));
}

public Issue getIssue(String key) {


return issues.get(key);
}

public void saveIssue(Issue issue) {


issues.put(issue.getKey(), issue);
}
}

We hard-code a single issue which we will be using for testing.

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.

We start with GetIssueUseCase in the usecase package:

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;

public GetIssueUseCase(IssueRepository repository) {


this.repository = repository;
}

public Issue execute(String key) {


return repository.getIssue(key);

106 | Chapter 7. Client-side scripting


Modern frontends with htmx

}
}

Finally, create a controller in the web package:

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;

public IssueController(GetIssueUseCase getIssueUseCase) { ①


this.getIssueUseCase = getIssueUseCase;
}

@GetMapping("/{key}")
public String showIssue(@PathVariable("key") String key,
Model model) {
Issue issue = getIssueUseCase.execute(key);
model.addAttribute("issue", issue); ②
return "issue"; ③
}
}

① Inject the GetIssueUseCase.

② Add the Issue object in the model so we can use it to populate the values of the template.

③ Use the issue.html template for rendering.

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"

Chapter 7. Client-side scripting | 107


Modern frontends with htmx

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>

108 | Chapter 7. Client-side scripting


Modern frontends with htmx

<dd class="text-gray-700" th:text="${issue.fixVersion}">1.0</dd>


</div>
</dl>
</div>
</body>
</html>

Create src/main/resources/templates/fragments.html for the 3 fragments that issue.html


uses:

<!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

Chapter 7. Client-side scripting | 109


Modern frontends with htmx

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.

③ The issue-summary-view visualizes the summary of an issue in a read-only view.

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:

 If you run npm run build && npm run


http://localhost:3000/issues/XXX-123 URL as well.
watch, you can use the

110 | Chapter 7. Client-side scripting


Modern frontends with htmx

Figure 20. Static rendering of issue properties

7.2.3. Inline editing of issue summary


The first feature we want to implement is an inline editing of the issue summary. When the user
hovers over the summary, an icon should appear to indicate that inline editing is possible.

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.

7.2.3.1. Visual indication

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

Chapter 7. Client-side scripting | 111


Modern frontends with htmx

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>

① 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.

Figure 21. Pencil icon shown on hover

7.2.3.2. Implementation happy flow

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:

112 | Chapter 7. Client-side scripting


Modern frontends with htmx

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>

① Add hx:get and hx-swap attributes to initiate the inline editing.

We added 2 htmx attributes to our issue-summary-view fragment:

• 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"; ⑤
}

① Allow only htmx to call this endpoint.

Chapter 7. Client-side scripting | 113


Modern frontends with htmx

② Create a SummaryUpdateFormData object to bind to the form.

③ Set the current summary on the form data object.


④ Add the form data object to the model.
⑤ Render the issue-summary-edit fragment from the fragments.html template.

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

public static class SummaryUpdateFormData {


@NotBlank
private String summary;

public String getSummary() {


return summary;
}

public void setSummary(String summary) {


this.summary = summary;
}
}

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}"

114 | Chapter 7. Client-side scripting


Modern frontends with htmx

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:

1. Avoid the default form submission as we want htmx to do the request.


2. Trigger the htmx request when the user presses Enter .
3. Do a PUT request to the /issues/{key}/summary endpoint. The response of the server will have
the "read-only" html of the summary again.
4. Swap the outerHTML with the received HTML response.

Note how cleanly this maps to our code:

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

Chapter 7. Client-side scripting | 115


Modern frontends with htmx

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>

① We have 5 extra attributes on this line:


• @submit.prevent="" is a way for Alpine to do an event.preventDefault() on the submit
event.
• With keyup[key=='Enter'] as the hx-trigger, htmx will send out the request in response
to pressing Enter .
• We define the hx-put attribute using Thymeleaf syntax, so we use hx:put and use the
standard Thymeleaf syntax for a URL with a path variable.
• Set hx-swap to outerHTML.

• 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;

public UpdateSummaryUseCase(IssueRepository repository) {


this.repository = repository;
}

public Issue execute(String key,

116 | Chapter 7. Client-side scripting


Modern frontends with htmx

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.

② Update the summary of the issue.


③ Save the issue again.

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"; ⑤
}

Issue issue = updateSummaryUseCase.execute(key, formData.


getSummary()); ⑥
model.addAttribute("issue", issue);
return "fragments :: issue-summary-view"; ⑦
}

① 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.

Chapter 7. Client-side scripting | 117


Modern frontends with htmx

⑥ 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.

This screenshot shows the inline editing in action:

Figure 22. Input field showing after click

After entering a new summary and pressing Enter :

118 | Chapter 7. Client-side scripting


Modern frontends with htmx

Figure 23. Summary updated after pressing ENTER

If the user tries to make the summary empty, a validation message is shown:

Chapter 7. Client-side scripting | 119


Modern frontends with htmx

Figure 24. Validation error if the summary is empty

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.

7.2.3.3. Implementation cancel flow

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:

htmx.ajax('GET', '/some-url', {target: '#my-target', swap: 'outerHTML'})

This would correspond to this HTML:

<div
hx-get="/some-url"
hx-target="#my-target"
hx-swap="outerHTML">

120 | Chapter 7. Client-side scripting


Modern frontends with htmx

...
</div>

Alpine allows handling keyboard events via the @keyup annotation. For example:

<div x-data=""
@keyup.enter="alert('Enter was pressed')">

</div>

We can now combine this:

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>

Chapter 7. Client-side scripting | 121


Modern frontends with htmx

</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";
}

Test again. It should now be possible to cancel the editing process.

7.2.3.4. Error handling

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.

Remember that we reacted to 3 events that htmx emits:

• 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']"

122 | Chapter 7. Client-side scripting


Modern frontends with htmx

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()"

Chapter 7. Client-side scripting | 123


Modern frontends with htmx

This will trigger for an event called someCustomEvent.

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

7.2.4. Using drag and drop


We can combine htmx with other JavaScript libraries as well. This section will show how to integrate
with SortableJS. The library will be used to allow the user to drag and drop sub-tasks to change their
order.

124 | Chapter 7. Client-side scripting


Modern frontends with htmx

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>

Chapter 7. Client-side scripting | 125


Modern frontends with htmx

</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>

① Iterate over the subtasks from the issue.


② Use Tailwind’s group modifier again to either show a puzzle icon by default, or show a different
icon when hovering to indicate to the user that a subtask can be dragged.
③ Translate the status enum.

To make that status field translation work, update application.properties:

src/main/resources/application.properties

spring.messages.basename=i18n/messages

Add a new file messages.properties in src/main/resources/i18n:

src/main/resources/i18n/messages.properties

Status.TODO=To Do
Status.IN_PROGRESS=In Progress
Status.DONE=Done

Add the SortableJS library through webjars in the pom.xml:

pom.xml

<dependency>
<groupId>org.webjars.npm</groupId>
<artifactId>sortablejs</artifactId>
<version>1.15.0</version>
</dependency>

Include the library in the main.html layout file:

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"

126 | Chapter 7. Client-side scripting


Modern frontends with htmx

th:src="@{/webjars/sortablejs/Sortable.min.js}"></script> ①
<th:block layout:fragment="script-content">

</th:block>

① Link to the SortableJS library.

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>

The application should now look like this:

Chapter 7. Client-side scripting | 127


Modern frontends with htmx

Figure 26. Static rendering of the sub-tasks

You can hover over the icon in front of each sub-task and drag-and-drop a sub-task to another
position.

128 | Chapter 7. Client-side scripting


Modern frontends with htmx

Figure 27. Dragging a sub-task

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">

Chapter 7. Client-side scripting | 129


Modern frontends with htmx

<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>
</form>

① There are 3 changes here:


• Replace the <div> with a <form> so all the hidden input values will be sent to the server when

130 | Chapter 7. Client-side scripting


Modern frontends with htmx

htmx does a request.


• Add hx-trigger="end" to have htmx react to the event sent out by SortableJS at the end of a
drag and drop action.
• Add hx:put with the URL to call.

② 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:

<input type="hidden" name="subTaskOrder" value="0">


<input type="hidden" name="subTaskOrder" value="1">
<input type="hidden" name="subTaskOrder" value="2">

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.

Add a new method to IssueController:

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.

② Use the ReorderSubtasksUseCase to update the issue and store it.

Chapter 7. Client-side scripting | 131


Modern frontends with htmx

③ 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;

public ReorderSubtasksUseCase(IssueRepository repository) {


this.repository = repository;
}

public Issue execute(String key,


int[] subTaskOrder) {
Issue issue = repository.getIssue(key);
List<SubTask> subTasks = issue.getSubTasks();
List<SubTask> reordered = new ArrayList<>();
for (int order : subTaskOrder) {
reordered.add(subTasks.get(order));
}
issue.setSubTasks(reordered);
repository.saveIssue(issue);
return issue;
}
}

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.

132 | Chapter 7. Client-side scripting


Modern frontends with htmx

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 7. Client-side scripting | 133


Modern frontends with htmx

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.

8.1. Htmx and security


For the most part, we don’t have to do anything. Once you are authenticated in the application, and
you have a session, htmx will be able to issue requests. Fundamentally, requests issued by htmx are
no different from requests done by the user itself. They are still requests done from the browser, so
for the server, they participate in the same session. The requests do not require an Authorization
header (with a JWT token for example) like you need for a Single Page Application.

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

8.2.1. Initialize project


The project will allow a user to log on and keep track of their bookmarks.

Run ttcli init to create a new project.

• Group: com.modernfrontendshtmx

• Artifact: bookmarks

• Project name: Bookmarks

• Spring Boot: 3.1.4

• Live reload: npm-based-with-tailwind

• Web dependencies: htmx

• Tailwind dependencies: forms

8.2.2. Add Spring Security

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>

134 | Chapter 8. Security


Modern frontends with htmx

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

Chapter 8. Security | 135


Modern frontends with htmx

SecurityFilterChain filterChain(HttpSecurity http) throws Exception


{ ④
return http
.authorizeHttpRequests(registry -> registry
.requestMatchers(HttpMethod.GET, "/css/**"
).permitAll() ⑤
.requestMatchers(HttpMethod.GET, "/webjars/**"
).permitAll() ⑥
.anyRequest().authenticated()) ⑦
.formLogin(Customizer.withDefaults()) ⑧
.build();
}
}

① Declare a UserDetailsService bean with a single in-memory user for demonstration purposes.

② Set the username to admin.

③ Set the password to admin.

④ Configure the security of the endpoints of the application via a SecurityFilterChain bean.

⑤ Allow GET requests towards the CSS files without authentication.

⑥ Allow GET requests towards the webjars without authentication.

⑦ 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.

8.3. Classic Thymeleaf setup


We will now implement adding a bookmark in the UI using a classic Thymeleaf setup with a page
reload for each addition. After that, we will refactor to use htmx to submit the form and indicate what
implications this has on security.

We have our Bookmark modeled as a record:

com.modernfrontendshtmx.bookmarks.Bookmark

package com.modernfrontendshtmx.bookmarks;

public record Bookmark(int id, String name, String url) {


}

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;

136 | Chapter 8. Security


Modern frontends with htmx

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:/"; ⑥
}

private Bookmark addBookmark(CreateBookmarkFormData formData) {


Bookmark bookmark = formData.toBookmark(autoIncrementCounter
.incrementAndGet());
bookmarkMap.put(bookmark.id(), bookmark);
return bookmark;
}
}

① Use an AtomicInteger as the "primary key generator".

② Keep the bookmarks indexed by primary key in a Map.

③ Put all the bookmarks in the Model so the home page can display them.

Chapter 8. Security | 137


Modern frontends with htmx

④ Endpoint to create a new bookmark.


⑤ Use the CreateBookmarkFormData class to map the input fields from the UI onto a Java object.

⑥ Redirect back to the home page after saving the bookmark.

We also need CreateBookmarkFormData to represent the form data when the user submits the
form:

com.modernfrontendshtmx.bookmarks.CreateBookmarkFormData

package com.modernfrontendshtmx.bookmarks;

public class CreateBookmarkFormData {


private String name;
private String url;

public String getName() {


return name;
}

public void setName(String name) {


this.name = name;
}

public String getUrl() {


return url;
}

public void setUrl(String url) {


this.url = url;
}

public Bookmark toBookmark(int id) {


return new Bookmark(id, name, url);
}
}

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>

138 | Chapter 8. Security


Modern frontends with htmx

<div layout:fragment="content" class="container mx-auto max-w-2xl">


<h1 class="text-2xl mb-4">Bookmarks</h1>
<form id="bookmark-creation-form"
th:object="${formData}"
th:action="@{/bookmarks/create}"
th:method="post"> ①
<div class="relative mb-4">
<label for="name" class="absolute -top-2 left-2 inline-block
bg-white px-1 text-xs font-medium text-gray-900">Name</label>
<input type="text" name="name" id="name" class="block w-full
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" placeholder="Thymeleaf">
</div>
<div class="relative mb-4">
<label for="url" class="absolute -top-2 left-2 inline-block
bg-white px-1 text-xs font-medium text-gray-900">Url</label>
<input type="text" name="url" id="url" class="block w-full
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"
placeholder="https://www.thymeleaf.org/">
</div>
<button type="submit" class="rounded-full bg-indigo-600 px-2.5
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">Save</button>
</form>

<div id="bookmarks-list" class="mt-4"> ②


<th:block th:each="bookmark : ${bookmarks}">
<div th:fragment="bookmark" class="bookmark mb-1">
<span th:text="${bookmark.name()}"></span>
<span>-</span>
<span th:text="${bookmark.url()}" class="text-gray-
500"></span>
</div>
</th:block>
</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>

Chapter 8. Security | 139


Modern frontends with htmx

</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.

If we run the application, we should get a login screen first:

Figure 28. Default login form from Spring Security

Using admin/admin, we can log on to our application:

140 | Chapter 8. Security


Modern frontends with htmx

Figure 29. Application after login

After adding a few entries, the application should look like this:

Chapter 8. Security | 141


Modern frontends with htmx

Figure 30. Three bookmarks added in the application

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 id="bookmark-creation-form" action="/bookmarks/create"


method="post">
<input type="hidden" name="_csrf"
value="oQUafXrd0cQrWdpHe8zWCITsSG6VSYQ3qT3EdyjEWeNHMWs7k2d8GRnutaEGP-5-
H-HiPbTeZQ-leLUaml73RxnxPdMkAlxa"> ①
<div class="relative mb-4">
...
</div>
<div class="relative mb-4">
...
</div>
<button type="submit" class="...">Save</button>

142 | Chapter 8. Security


Modern frontends with htmx

</form>

① Hidden input inserted by Thymeleaf.

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.

Learn more about Cross Site Request Forgery (CSRF) at https://owasp.org/www-


community/attacks/csrf.
 To learn more about it in the context of Spring, see the CSRF section in the spring
security docs.

Notice how we currently get a page reload for each form submit. Let’s use htmx to avoid the page
reload.

8.4. Use htmx for adding a bookmark


To use htmx for submitting the form, we add 3 htmx attributes to our form:

<h1 class="text-2xl mb-4">Bookmarks</h1>


<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"> ①
...

① Three additional attributes for htmx:


• hx:post to have htmx issue a POST request to /bookmarks.

• 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,

Chapter 8. Security | 143


Modern frontends with htmx

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">

Be aware that removing those attributes breaks progressive enhancement. If it is


 important that your application also works with JavaScript disabled, then be sure to
keep those.

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.

144 | Chapter 8. Security


Modern frontends with htmx

<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.

8.5. Deleting a bookmark


Let’s add a delete button next to each bookmark so our user is able to remove a bookmark again.

src/main/resources/templates/index.html

<div id="bookmarks-list" class="mt-4">


<th:block th:each="bookmark : ${bookmarks}">
<div th:fragment="bookmark" class="bookmark mb-1">
<span th:text="${bookmark.name()}"></span>
<span>-</span>
<span th:text="${bookmark.url()}" class="text-gray-
500"></span>
<button class="ml-4 rounded-full bg-red-400 px-2.5 py-1
text-sm"

hx:delete="@{/bookmarks/{id}(id=${bookmark.id()})}"
hx-target="closest .bookmark"
hx-swap="outerHTML">Delete</button> ①
</div>
</th:block>
</div>

① Button added with 3 htmx attributes:


• hx:delete to issue a DELETE request.

• 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.

Chapter 8. Security | 145


Modern frontends with htmx

Update HomeController with an endpoint to delete a bookmark:

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?

Quite a few, actually.

8.5.1. Delete with hidden input


We can add a hidden input and explicitly tell htmx to include the value of the hidden input in our
request:

src/main/resources/templates/index.html

<div id="bookmarks-list" class="mt-4">


<th:block th:each="bookmark : ${bookmarks}">
<div th:fragment="bookmark" class="bookmark mb-1">
<span th:text="${bookmark.name()}"></span>
<span>-</span>
<span th:text="${bookmark.url()}" class="text-gray-
500"></span>
<button class="ml-4 rounded-full bg-red-400 px-2.5 py-1
text-sm"

hx:delete="@{/bookmarks/{id}(id=${bookmark.id()})}"
hx-target="closest .bookmark"
hx-swap="outerHTML"
hx-include=".delete-csrf-token">Delete</button>

146 | Chapter 8. Security


Modern frontends with htmx


<input class="delete-csrf-token" type="hidden"
th:name="${_csrf.parameterName}"
th:value="${_csrf.token}"/> ②
</div>
</th:block>
</div>

① Use hx-include to refer to the hidden input below.

② 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.

8.5.2. Delete with meta tags

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>

...

<div id="bookmarks-list" class="mt-4">


<th:block th:each="bookmark : ${bookmarks}">
<div th:fragment="bookmark" class="bookmark mb-1">
<span th:text="${bookmark.name()}"></span>
<span>-</span>
<span th:text="${bookmark.url()}" class="text-gray-
500"></span>
<button class="ml-4 rounded-full bg-red-400 px-2.5 py-1
text-sm"

hx:delete="@{/bookmarks/{id}(id=${bookmark.id()})}"
hx-target="closest .bookmark"
hx-swap="outerHTML">Delete</button>
</div>

Chapter 8. Security | 147


Modern frontends with htmx

</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.

8.5.3. Delete with Thymeleaf inlining syntax for JavaScript


The last alternative is using Thymeleaf inlining syntax for JavaScript. Using that, we don’t need the
<meta> tags in the <head> section.

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}">

...

<div id="bookmarks-list" class="mt-4">


<th:block th:each="bookmark : ${bookmarks}">
<div th:fragment="bookmark" class="bookmark mb-1">
<span th:text="${bookmark.name()}"></span>
<span>-</span>
<span th:text="${bookmark.url()}" class="text-gray-
500"></span>
<button class="ml-4 rounded-full bg-red-400 px-2.5 py-1
text-sm"

148 | Chapter 8. Security


Modern frontends with htmx

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>

① Add the th:inline="javascript" to enable the Thymeleaf inlining for JavaScript.

② 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.

8.6. Handle logout


When the login session of a user expires in a traditional Thymeleaf application, the application
redirects the user to the login page. The user then logs on, and the page the user was working with is
shown again.

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:

1. Start the application.


2. Open the browser at http://localhost:8080.
3. Add a bookmark.
4. While the browser remains open, restart the Spring Boot application.
5. Try to add another bookmark.

If you save the bookmark, the browser will show something like this:

Chapter 8. Security | 149


Modern frontends with htmx

Figure 31. Login form swapped into the application itself

This is obviously not the behavior that we want.

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.

To use it, update WebSecurityConfiguration like this:

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()

150 | Chapter 8. Security


Modern frontends with htmx

.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.exceptionHandling(exception ->
exception.defaultAuthenticationEntryPointFor
(entryPoint,
requestMatcher)) ③
.build();
}

① Declare an instance of HxRefreshHeaderAuthenticationEntryPoint.

② Declare an instance of RequestHeaderRequestMatcher since we only want this behavior for


requests that htmx has sent, which we identify via the HX-Request request header.

③ 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.

Chapter 8. Security | 151


Modern frontends with htmx

Chapter 9. Project 2: Contact application


In this chapter, we will develop a web application designed for managing contacts. This application is a
Java adaptation of the one presented in the book Hypermedia Systems. The book was authored by
Carson Gross, the founder of htmx, as well as Adam Stepinski and Deniz Akşimşek. It explains in great
detail the idea of hypermedia that forms the basics of how a web application can be built. This is very
interesting if you want to have a bit more background information on the things we are doing here, so
I highly recommend reading it.

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.

9.1. Setup project


As usual, we will use ttcli to generate a project to get started:

• Group: com.modernfrontendshtmx

• Artifact: contacts-app

• Project Name: Contacts App

• Spring Boot version: 3.1.4

• Live reload: NPM based with Tailwind CSS

• Web dependencies: htmx, alpinejs

• Tailwind dependencies: forms

To get started, we create a domain model class Contact like this:

package com.modernfrontendshtmx.contactsapp.contact;

public class Contact {


private final ContactId id;
private String givenName;
private String familyName;
private String phone;
private String email;

public Contact(ContactId id,


String givenName,
String familyName,
String phone,
String email) {
this.id = id;
this.givenName = givenName;
this.familyName = familyName;
this.phone = phone;

152 | Chapter 9. Project 2: Contact application


Modern frontends with htmx

this.email = email;
}

// getters and setters omitted


}

And with ContactId:

package com.modernfrontendshtmx.contactsapp.contact;

public record ContactId(long value) {


}

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;

public interface ContactRepository {

List<Contact> findAll();
}

To get started, we can only retrieve contacts. We will be adding more methods to this repository soon.

We have an in-memory implementation of the repository:

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;

Chapter 9. Project 2: Contact application | 153


Modern frontends with htmx

@Repository
public class InMemoryContactRepository implements ContactRepository {

private final Map<ContactId, Contact> values = new HashMap<>();

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());
}
}

There are 3 contacts hardcoded, so we have something to test with.

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;

154 | Chapter 9. Project 2: Contact application


Modern frontends with htmx

import java.util.List;

@Service
public class ContactService {
private final ContactRepository repository;

public ContactService(ContactRepository repository) {


this.repository = repository;
}

public List<Contact> getAll() {


return repository.findAll();
}
}

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;

public ContactController(ContactService service) { ②


this.service = service;
}

@GetMapping
public String viewContacts(Model model) {
List<Contact> contactList = service.getAll(); ③

Chapter 9. Project 2: Contact application | 155


Modern frontends with htmx

model.addAttribute("contacts", contactList); ④

return "contacts/list"; ⑤
}
}

① All endpoints will be using the /contacts prefix.

② Inject the ContactService.

③ Get the list of contacts from the repository.


④ Pass the contacts into the model so the Thymeleaf template can render the contents.
⑤ Have the contacts/list.html template rendered when this method is called.

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"; ①
}
}

① Redirect to /contacts when / is accessed.

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}">

156 | Chapter 9. Project 2: Contact application


Modern frontends with htmx

</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.

Next, create the templates/contacts/list.html file:

<!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>

Chapter 9. Project 2: Contact application | 157


Modern frontends with htmx

</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

158 | Chapter 9. Project 2: Contact application


Modern frontends with htmx

pl-3 pr-4 text-right text-sm font-medium sm:pr-0 flex gap-x-2">


<a class="text-indigo-400 hover:text-
indigo-900"

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 {

Chapter 9. Project 2: Contact application | 159


Modern frontends with htmx

@apply whitespace-nowrap px-3 py-4 text-sm text-gray-500;


}

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.

Figure 32. Contact app showing list of contacts

9.2. Add a contact


Our little application already shows some information, but is not really functional other than that.
Let’s make it possible to add a contact in the application.

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;

160 | Chapter 9. Project 2: Contact application


Modern frontends with htmx

public interface ContactRepository {


ContactId nextId(); ①

List<Contact> findAll();

void save(Contact contact); ②


}

① Method to get a new unique primary key object.


② Method to save a Contact instance.

Implement the methods in the implementation class:

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 {

private final AtomicLong sequence = new AtomicLong(); ①


private final Map<ContactId, Contact> values = new HashMap<>();

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",

Chapter 9. Project 2: Contact application | 161


Modern frontends with htmx

"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.

② Use the new save() method to store the hard-coded contacts.

③ Create a unique ContactId instance.

④ Save the contact in the in-memory map.

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;

public ContactService(ContactRepository repository) {

162 | Chapter 9. Project 2: Contact application


Modern frontends with htmx

this.repository = repository;
}

public List<Contact> getAll() {


return repository.findAll();
}

public Contact storeNewContact(String givenName,


String familyName,
String phone,
String email) { ①
Contact contact = new Contact(repository.nextId(),
givenName,
familyName,
phone,
email);
repository.save(contact);
return contact;
}
}

① 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;

public class CreateContactFormData {


@NotBlank
private String givenName;
@NotBlank
private String familyName;
@NotBlank
private String phone;
@Email
private String email;

//getters and setters omitted


}

Because of the validation annotations, we need to add an extra dependency in our pom.xml:

Chapter 9. Project 2: Contact application | 163


Modern frontends with htmx

<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;

public ContactController(ContactService service) {


this.service = 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());

164 | Chapter 9. Project 2: Contact application


Modern frontends with htmx

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}"

Chapter 9. Project 2: Contact application | 165


Modern frontends with htmx

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>

<div th:fragment="emailinput(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:

<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">


<a th:href="@{/contacts/new}"
class="button-primary">
Add contact
</a>
</div>

The final change is adding the CSS class .button-primary to our application.css:

166 | Chapter 9. Project 2: Contact application


Modern frontends with htmx

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:

Figure 33. Add a new contact

There is also validation of the input parameters:

Chapter 9. Project 2: Contact application | 167


Modern frontends with htmx

Figure 34. Validation error if required properties are not present

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:

<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

168 | Chapter 9. Project 2: Contact application


Modern frontends with htmx

</button>
</div>

to:

<div class="flex gap-x-2 items-center">


<form th:action="@{/contacts}"
th:method="get">
<label for="search" class="text-sm font-medium leading-6
text-gray-900">Search</label>
<input type="search" name="q" id="search"
th:value="${query}"
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>
</form>
</div>

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";

Chapter 9. Project 2: Contact application | 169


Modern frontends with htmx

① Declare a request parameter with name q and set it as optional.

② 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.

The service will delegate the work to the repository:

com.modernfrontendshtmx.contactsapp.contact.service.ContactService

public List<Contact> searchContacts(String query) {


return repository.findAllWithNameContaining(query);
}

Update ContactRepository and InMemoryContactRepository:

com.modernfrontendshtmx.contactsapp.contact.repository.ContactRepository

List<Contact> findAllWithNameContaining(String query);

com.modernfrontendshtmx.contactsapp.contact.repository.InMemoryContactRepository

@Override
public List<Contact> findAllWithNameContaining(String query) {
return values.values()
.stream()
.filter(contact -> contact.hasName(query)) ①
.toList();
}

① Filter the contacts by name using the hasName() function on Contact.

The filter uses this hasName() function:

com.modernfrontendshtmx.contactsapp.contact.Contact

public boolean hasName(String name) {


return givenName.toLowerCase(Locale.ROOT).contains(name)
|| familyName.toLowerCase(Locale.ROOT).contains(name);
}

With this code in place, we can search for contacts. Here, we searched for "Ada":

170 | Chapter 9. Project 2: Contact application


Modern frontends with htmx

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.

9.4. View contact


Each contact in the list has an edit and a view link which are not yet implemented. Let’s start with the
view link.

To view a single contact, we need a way to retrieve a single contact from the repository:

com.modernfrontendshtmx.contactsapp.contact.repository.ContactRepository

Optional<Contact> findById(ContactId contactId);

com.modernfrontendshtmx.contactsapp.contact.repository.InMemoryContactRepository

@Override
public Optional<Contact> findById(ContactId contactId) {
return Optional.ofNullable(values.get(contactId));
}

Chapter 9. Project 2: Contact application | 171


Modern frontends with htmx

We relay the method through the ContactService so the controller can use it:

com.modernfrontendshtmx.contactsapp.contact.service.ContactService

public Contact getContact(ContactId contactId) {


return repository.findById(contactId)
.orElseThrow(() -> new ContactNotFoundException(contactId));
}

If the contact cannot be found, the service method will throw a ContactNotFoundException:

package com.modernfrontendshtmx.contactsapp.contact;

public class ContactNotFoundException extends RuntimeException {


public ContactNotFoundException(ContactId contactId) {
super("Could not find contact with id " + contactId);
}
}

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";
}

The controller uses the templates/contacts/view.html Thymeleaf template:

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>

172 | Chapter 9. Project 2: Contact application


Modern frontends with htmx

<div>
<div>Phone: <span th:text="${contact.phone}"></span></div>
<div>Email: <span th:text="${contact.email}"></span></div>
</div>

<div class="flex items-center gap-4 mt-4">


<a th:href="@{/contacts/{id}/edit(id=${contact.id.value()})}"
class="button-primary">Edit</a>
<a th:href="@{/contacts}"
class="button-secondary">Back</a>
</div>
</div>
</body>
</html>

There is also one small CSS change:

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

<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm


font-medium sm:pr-0 flex gap-x-2">
<a class="text-indigo-400 hover:text-indigo-900"
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>

Try to view any of the contacts now. The application should look similar to this:

Chapter 9. Project 2: Contact application | 173


Modern frontends with htmx

Figure 36. View a single contact

9.5. Edit and delete a contact


We can already add and view contacts. The next logical step is to allow editing of contacts and also
deleting them.

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
}

We can use this in edit.html like this:

174 | Chapter 9. Project 2: Contact application


Modern frontends with htmx

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">

Chapter 9. Project 2: Contact application | 175


Modern frontends with htmx

<button type="submit" class="button-primary-danger">Delete


Contact</button>
</form>

<p>
<a href="/contacts"
class="button-secondary">Back</a>
</p>
</div>
</div>
</body>
</html>

① Set the page title depending on the edit mode.


② Set the page subtitle depending on the edit mode.
③ Set the correct th:action depending on the edit mode.

④ 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";

176 | Chapter 9. Project 2: Contact application


Modern frontends with htmx

...

@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";
}

① Set the editMode to CREATE.

② Also set the editMode to CREATE if there was a validation error and the page needs to be re-
rendered.

Chapter 9. Project 2: Contact application | 177


Modern frontends with htmx

③ Implementation of the GET part of editing a contact.

④ Implementation of the POST part of editing a contact.

⑤ Method to delete a contact.

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;

public class EditContactFormData {


private long id;
@NotBlank
private String givenName;
@NotBlank
private String familyName;
@NotBlank
private String phone;
@Email
private String email;

public static EditContactFormData from(Contact contact) { ①


EditContactFormData formData = new EditContactFormData();
formData.setId(contact.getId().value());
formData.setGivenName(contact.getGivenName());
formData.setFamilyName(contact.getFamilyName());
formData.setPhone(contact.getPhone());
formData.setEmail(contact.getEmail());

return formData;
}

//getters and setters omitted


}

① Factory method to convert a Contact into an EditContactFormData.

The ContactService needs 2 additonal methods:

178 | Chapter 9. Project 2: Contact application


Modern frontends with htmx

com.modernfrontendshtmx.contactsapp.contact.service.ContactService

public void updateContact(ContactId contactId,


String givenName,
String familyName,
String phone,
String email) {
Contact contact = getContact(contactId);
contact.setGivenName(givenName);
contact.setFamilyName(familyName);
contact.setPhone(phone);
contact.setEmail(email);

repository.save(contact);
}

public void deleteContact(ContactId contactId) {


repository.deleteById(contactId);
}

Finally, we need to update our repository as well:

com.modernfrontendshtmx.contactsapp.contact.repository.ContactRepository

void deleteById(ContactId contactId);

com.modernfrontendshtmx.contactsapp.contact.repository.InMemoryContactRepository

@Override
public void deleteById(ContactId contactId) {
values.remove(contactId);
}

To style the delete button, add this extra class to application.css:

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;
}

Running the application, we can now edit and delete contacts.

Chapter 9. Project 2: Contact application | 179


Modern frontends with htmx

Figure 37. Updating a contact

9.6. Delete using htmx


By now, we have a nice little web application in web 1.0 style. Everything is functional, but it does not
quite feel like a modern application at this point. Let’s add some htmx to turn that around.

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!"); ②

RedirectView redirectView = new RedirectView("/contacts"); ③


redirectView.setStatusCode(HttpStatus.SEE_OTHER); ④

180 | Chapter 9. Project 2: Contact application


Modern frontends with htmx

return redirectView;
}

① Inject RedirectAttributes to allow adding a flash attribute.

② Use a RedirectView instance instead of "redirect:/contacts" string to be able to set the


status code.
③ Set the status code to 303 See Other.

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

<div th:if="${editMode?.name() == 'UPDATE'}" class="flex mt-8 gap-


4">
<button type="submit"
class="button-primary-danger"
hx:confirm="|Are you sure you want to delete the contact
${formData.givenName} ${formData.familyName}?|"
hx:delete="@{/contacts/{id}(id=${formData.id})}"
hx-target="body"
hx-push-url="true"
>Delete Contact</button> ①

<p>
<a href="/contacts"
class="button-secondary">Back</a>
</p>
</div>

① The following htmx attributes have been added to the <button>:


• hx:confirm: This will show a confirmation message to the user. If the user cancels, the hx-
delete will not be done.
• hx:delete: The endpoint that will be called when the user confirms with a DELETE request.

• 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

Chapter 9. Project 2: Contact application | 181


Modern frontends with htmx

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.

By default, hx-confirm uses the browser native dialog. See A Customized


 Confirmation UI on the htmx website for more information if you want to use a
custom dialog using sweetalert2.

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:

<div class="container mx-auto max-w-2xl mt-4">


<div th:if="${successMessage}"> ①
<div class="rounded-md bg-green-50 p-4 mb-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-green-400" viewBox="0 0 20
20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16
8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-
1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-
rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-green-800"
th:text="${successMessage}">Successfully uploaded</p>
</div>
<div class="ml-auto pl-3">
</div>
</div>
</div>
</div>
<main layout:fragment="content">
</main>
</div>

① 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).

These screenshots show the functionality in action:

182 | Chapter 9. Project 2: Contact application


Modern frontends with htmx

Figure 38. Confirmation message before deleting a contact

Chapter 9. Project 2: Contact application | 183


Modern frontends with htmx

Figure 39. Flash message and updated URL without browser refresh after delete

9.7. Inline validation of duplicate email addresses


Our beloved product manager just came by our cubicle to ask for a new requirement. How can we
avoid that duplicate email addresses are entered into our contact application? We can quite easily
check this on the server after the form was submitted. But that would feel a bit clunky. We want fancy
stuff. We want inline validation while the user is typing, so we can inform them before the form is
submitted.

Let’s see how we can use the power of htmx to implement this.

9.7.1. Implement custom validator


We’ll start by writing a custom validator. If you have never written a custom validator before, you
might want to read up on it in Taming Thymeleaf, or check out the blog post Custom validator with
Spring Boot. We need to create two classes: an annotation to trigger the validator, and the validator
itself.

This is the annotation @NoDuplicateContactsByEmail:

package com.modernfrontendshtmx.contactsapp.contact.web;

import jakarta.validation.Constraint;

184 | Chapter 9. Project 2: Contact application


Modern frontends with htmx

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";

Class<?>[] groups() default {};

Class<? extends Payload>[] payload() default {};


}

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 class NoDuplicateContactsByEmailValidator implements


ConstraintValidator<NoDuplicateContactsByEmail, CreateContactFormData> {

private final ContactService contactService;

public NoDuplicateContactsByEmailValidator(ContactService
contactService) {
this.contactService = contactService;
}

@Override
public boolean isValid(CreateContactFormData formData,
ConstraintValidatorContext context) {
if (contactService.contactWithEmailExists(formData.getEmail()))
{ ②

Chapter 9. Project 2: Contact application | 185


Modern frontends with htmx

context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate("There is
already a contact with this email address")
.addPropertyNode("email") ③
.addConstraintViolation();

return false;
}

return true;
}
}

① Implement the ConstraintValidator interface with the generic types bound to


NoDuplicateContactsByEmail (our annotation) and CreateContactFormData (the class
where we will apply the annotation on).
② Use a new method in the ContactService to check if there is already a contact that has the
entered email address.
③ Indicate that the email property of the CreateContactFormData should be highlighted as
invalid.

The new method in ContactService just delegates to the repository:

com.modernfrontendshtmx.contactsapp.contact.service.ContactService

public boolean contactWithEmailExists(String email) {


return repository.existsByEmail(email);
}

Repository updates:

com.modernfrontendshtmx.contactsapp.contact.repository.ContactRepository

boolean existsByEmail(String email);

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:

186 | Chapter 9. Project 2: Contact application


Modern frontends with htmx

@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.

9.7.2. Trigger validation while typing


The basic idea is that we add an extra endpoint in our controller that htmx will call when something is
typed into the email input field. That endpoint will use the same CreateContactFormData object
that we use when we actually create the user, but now just validates it without storing anything. The
response is the HTML again with the form to create a user, optionally including an error message
about duplicate email addresses. From that fully rendered page that htmx receives in the response,
we will pick the email input component and swap it with what is already on the page currently.

This is the new controller method that will do the validation:

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"; ⑤
}

① Expose a GET request on /contacts/new.

② Only htmx should call this endpoint.


③ Use @Valid to have Spring validate the form data.

④ 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.

<div th:fragment="emailinput(labelText, fieldName, inlineValidationUrl)"


th:id="|${fieldName}-form-element|"
th:class="${cssClass}"> ①
<label th:for="${fieldName}" class="block text-sm font-medium

Chapter 9. Project 2: Contact application | 187


Modern frontends with htmx

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:

188 | Chapter 9. Project 2: Contact application


Modern frontends with htmx

• 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>

① Add the /contacts/new URL as validation URL.

If you try this, you should get a validation error when typing an email address that already exists.

Chapter 9. Project 2: Contact application | 189


Modern frontends with htmx

Figure 40. Validation error shown while typing

9.7.3. Improve the user experience


You will probably notice that it is quite hard to type an email address. The reason for this, is that after
the debounce time, a request to the server is done to check if the email address is a duplicate. When
the response comes back, htmx swaps the HTML input element where you are currently typing with
the new one coming back from the server. Although it gets focus again, the cursor position is not
where it was as you where typing in the field.

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}__'),

190 | Chapter 9. Project 2: Contact application


Modern frontends with htmx

', ')}"
class="mt-2 text-sm text-red-600">Field validation error
message(s).</p>

On the <input> itself, replace:

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

<div th:fragment="emailinput(labelText, fieldName, inlineValidationUrl)"


th:id="|${fieldName}-form-element|"
th:class="${cssClass}"
th:x-data="|{showErrorIcon:
${#fields.hasErrors('__${fieldName}__')}}|"
th:x-on:htmx:after-settle="|showErrorIcon =
!document.getElementById('${fieldName}-
error').classList.contains('hidden')|"
>

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"

Chapter 9. Project 2: Contact application | 191


Modern frontends with htmx

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>

With all this in place, we have the following behavior:

• 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.

9.7.4. Use hx-validate to avoid unnecessary requests


One minor improvement we can still make is that we avoid that the validation request is sent to the
server as long as we know client-side that the email address is not a valid email address anyway.
Because we use the type="email" for the <input>, the form cannot be submitted until the client-
side validation is ok. However, the htmx request currently does not care about that and will send the
request to check for duplicate email addresses anyway.

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

192 | Chapter 9. Project 2: Contact application


Modern frontends with htmx

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.

9.8.1. Generate contacts using Datafaker


To avoid manually having to create many contacts to test the pagination, we will use the Datafaker
library to generate sample data for us.

Add the library in the pom.xml:

pom.xml

<dependency>
<groupId>net.datafaker</groupId>
<artifactId>datafaker</artifactId>
<version>2.0.0</version>
</dependency>

Update InMemoryContactRepository to use the library to generate 100 contacts:

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))));
}
}

9.8.2. Manual pagination


We’ll start by implementing manual pagation. This is: adding a Previous and Next button in the
application below the table. After that, we will add more interactive loading patterns based on this.

Chapter 9. Project 2: Contact application | 193


Modern frontends with htmx

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

Page<Contact> findAllOrderedByName(int page,


int size);

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;

public record Page<T>(List<T> values,


int number,
int size,
int totalElements) {
}

Now we can implement the actual pagination in InMemoryContactRepository:

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!

194 | Chapter 9. Project 2: Contact application


Modern frontends with htmx

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

public Page<Contact> getAll(int page) {


return repository.findAllOrderedByName(page, 10);
}

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";
}

① Declare optional page request parameter.

② Use the new service method.


③ Add metadata about the current page as attributes in the model.

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

...

Chapter 9. Project 2: Contact application | 195


Modern frontends with htmx

</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;
}

This is how the application looks with the pagination:

196 | Chapter 9. Project 2: Contact application


Modern frontends with htmx

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.

9.8.3. Click to load


With a small change to the Thymeleaf template and some htmx magic, we can implement the "click to
load" design pattern. With this pattern, the user presses a button to load more data and the table is
appended with the next page of data without refreshing the webpage.

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>

Chapter 9. Project 2: Contact application | 197


Modern frontends with htmx

</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.

Don’t forget to also remove the next and previous links.

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

198 | Chapter 9. Project 2: Contact application


Modern frontends with htmx

9.8.4. Infinite scroll


It is quite easy to go from the "Click to load" pattern to infinite scroll with htmx.

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>
...

① This element has the following changes:


• The <button> is replaced with a <span>

• 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.

9.9. Active search


A nice usability improvement would be that a user can just start typing in the search box and we show
the matching contacts. In this section, we will use a trigger like we did for the duplicate email
validation, but we will need to expand things a bit to make it work in all conditions. We will also
transparently update the URL in the browser so that a full refresh of the page will still show the same
result.

9.9.1. Search pagination


The first thing we need to do is update our repository to support searching for contact names in a
paginated way. Replace the return type List<Contact> on the findAllWithNameContaining
method with Page<Contact> and add the pagination parameters:

Chapter 9. Project 2: Contact application | 199


Modern frontends with htmx

com.modernfrontendshtmx.contactsapp.contact.repository.ContactRepository

Page<Contact> findAllWithNameContaining(String query,


int page,
int size);

Now implement the method in InMemoryContactRepository:

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());
}

Update ContactService to use the updated repository method:

com.modernfrontendshtmx.contactsapp.contact.service.ContactService

public Page<Contact> searchContacts(String query,


int page) {
return repository.findAllWithNameContaining(query,
page,
10);
}

Update ContactController for the updated service method:

com.modernfrontendshtmx.contactsapp.contact.web.ContactController

@GetMapping

200 | Chapter 9. Project 2: Contact application


Modern frontends with htmx

public String viewContacts(Model model,


@RequestParam(value = "q", required = false)
String query,
@RequestParam(value = "page", required =
false, defaultValue = "0") int page) {
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());

return "contacts/list";
}

① Use the page request parameter also when searching.

② Add the pagination-related model attributes on all cases.

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>

Chapter 9. Project 2: Contact application | 201


Modern frontends with htmx

</tr>
</tbody>
...

① Add q=${query} to the hx:get attribute.

9.9.2. Search on type

We can now update the search <input> with some htmx attributes:

<div class="flex gap-x-2 items-center">


<form th:action="@{/contacts}"
th:method="get">
<label for="search" class="text-sm font-medium leading-6
text-gray-900">Search</label>
<input type="search" name="q" id="search"
th:value="${query}"
hx:get="@{/contacts}"
hx-trigger="search, keyup delay:200ms changed"
hx-target="tbody"
hx-swap="outerHTML"
hx-push-url="true"
hx-indicator="#spinner"
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>
</form>
<svg id="spinner"
class="htmx-indicator animate-spin ml-3 mr-3 h-5 w-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>

202 | Chapter 9. Project 2: Contact application


Modern frontends with htmx

</div>

① The following attributes are configured:


• hx:get: Issue a GET request to retrieve the contacts. Because the hx-get is on the <input>
itself, the value of the input is also sent in the request.
• hx-trigger: Trigger the request as soon as the user stops typing for 200ms.

• 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.

② SVG for progress indication.

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";
}
}

Chapter 9. Project 2: Contact application | 203


Modern frontends with htmx

① Inject HtmxRequest to be able to query if the current request is an htmx request or not.

② Check if the request is an htmx request.


③ Have Thymeleaf only render the <tbody> of the contacts list.

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"

9.10. Delete row from list


It is currently possible to delete a contact by first going to the edit screen and select 'Delete' there. We
will now make it possible to delete a contact directly from the list. We will also add a nice animation to
make it look really cool!

Let us start by adding a delete link for each row that we display. Update list.html:

templates/contacts/list.html

<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-


sm font-medium sm:pr-0 flex gap-x-2">
<a class="text-indigo-400 hover:text-indigo-900"
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>
<a href="#"
class="text-indigo-400 hover:text-indigo-900"
hx:delete="@{/contacts/{id}(id=${contact.id.value()})}"
hx:confirm="|Are you sure you want to delete the contact
${contact.givenName} ${contact.familyName}?|"
hx-swap="outerHTML swap:1s"
hx-target="closest tr">Delete</a> ①
</td>

① A new <a> tag is added with the following htmx attributes:


• hx-delete to issue a DELETE request.

• 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.

204 | Chapter 9. Project 2: Contact application


Modern frontends with htmx

• 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:

• The new link on the list of contacts.


• The old button when editing a single contact.

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.

After that, we can update ContactController:

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!");

RedirectView redirectView = new RedirectView("/contacts");


redirectView.setStatusCode(HttpStatus.SEE_OTHER);
return HtmxResponse.builder()
.view(redirectView)
.build(); ③
} else {
return HtmxResponse.builder().build(); ④
}
}

① Inject HtmxRequest and change the return type to HtmxResponse.

② 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.

A last change is adding a CSS animation to application.css:

Chapter 9. Project 2: Contact application | 205


Modern frontends with htmx

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;
}
}

The animation works like this:

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.

9.10.1. Fix the delete button redirect


If you try the 'Delete' button on the edit page, you will notice that the redirect after the delete
happens as it should. But the page shows just the contacts table and not the full page anymore with
the search input and the 'Create contact' button.

The reason for this is that the redirect was triggered from an htmx request and htmx will honor the

206 | Chapter 9. Project 2: Contact application


Modern frontends with htmx

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";
}
}

① Check for the delete-button trigger id.

Now the list of contacts are shown properly again after the redirect.

9.11. Archive list of contacts


The last thing we will add to this contacts application is the ability to archive a list of contacts into a
CSV file that can be downloaded. The application will show a progress bar while the archiving is in
progress and when it is done a download link is shown.

Chapter 9. Project 2: Contact application | 207


Modern frontends with htmx

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<>();

public Archiver(ExecutorService executorService,


ContactRepository contactRepository) {
this.executorService = executorService;
this.contactRepository = contactRepository;
}

public ArchiveId startArchiving() { ①


ArchiveId id = new ArchiveId(UUID.randomUUID());
archives.put(id, new ArchiveProcessInfo()); ②
Future<String> future = executorService.submit(() -> { ③
try {
StringBuilder builder = new StringBuilder();
addCsvHeader(builder);
Page<Contact> page;
int currentPage = 0;
do {
page = contactRepository.findAllOrderedByName
(currentPage, 10);

208 | Chapter 9. Project 2: Contact application


Modern frontends with htmx

for (Contact contact : page.values()) {


addCsvRow(builder, contact);
}

int elementsArchived = (page.number() + 1) * page


.size();
int progress = (int) ((double) elementsArchived /
page.totalElements() * 100); ④
archives.get(id).setProgress(progress);
currentPage++;
// Fake some work to be able to have a nice progress
bar
Thread.sleep(200);
} while ((page.number() + 1) * page.size() < page
.totalElements()); ⑤

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;
}

public ArchiveProcessInfo getArchiveProcessInfo(ArchiveId archiveId)


{ ⑦
return archives.get(archiveId);
}

private static void addCsvHeader(StringBuilder builder) {


builder.append("Given name, Family name, Email, Phone")
.append(System.lineSeparator());
}

private static void addCsvRow(StringBuilder builder, Contact


contact) {
builder.append(contact.getGivenName())
.append(",")
.append(contact.getFamilyName())

Chapter 9. Project 2: Contact application | 209


Modern frontends with htmx

.append(",")
.append(contact.getEmail())
.append(",")
.append(contact.getPhone())
.append(System.lineSeparator());
}

① Method to start the archiving process.


② Store the random ArchiveId mapped to a new ArchiveProcessInfo object that is used to track
the current progress of the archiving process.
③ Submit a lambda that generates the CSV string to the executorService so the actual archiving is
done on a separate thread. This is needed so we will be able to return already something in the
controller and keep showing progress while the export is ongoing.
④ Calculate the current progress by comparing the number of contacts that have been exported
already and the total number of contacts in the application.
⑤ Keep exporting page by page until there are no more pages left to export.
⑥ Return the generated CSV string.
⑦ Method to get the current progress information given a certain archive id.

To support this class, we need to also have an ArchiveId and ArchiveProcessInfo:

package com.modernfrontendshtmx.contactsapp.contact.service;

import java.util.UUID;

public record ArchiveId(UUID value) {


}

package com.modernfrontendshtmx.contactsapp.contact.service;

import org.springframework.util.Assert;

import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicInteger;

public final class ArchiveProcessInfo {


private final AtomicInteger progress = new AtomicInteger(0);
private Status status = Status.RUNNING;
private Future<String> future;

public int getProgress() {


return progress.intValue();

210 | Chapter 9. Project 2: Contact application


Modern frontends with htmx

public void setProgress(int progress) {


Assert.isTrue(progress >= 0 && progress <= 100, "Progress should
be between 0 and 100");
this.progress.set(progress);
}

public Status getStatus() {


return status;
}

public void setStatus(Status status) {


this.status = status;
}

public Future<String> getFuture() {


return future;
}

public void setFuture(Future<String> future) {


this.future = future;
}

enum Status { ①
RUNNING,
COMPLETE,
FAILED
}
}

① Enum that keeps track of the archiving status.

There is no ExecutorService by default in a Spring Boot application, so we need to define one as a


bean manually so it can be injected into the Archiver:

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

Chapter 9. Project 2: Contact application | 211


Modern frontends with htmx

public class ContactsAppApplicationConfiguration {

@Bean
public ExecutorService executorService() {
return Executors.newCachedThreadPool();
}

9.11.2. Create an archive


Add a button to start the archiving on the list of contacts page:

templates/contacts/list.html

<div class="mt-4 flex">


<div id="archive-ui" hx-target="this" hx-swap="outerHTML"
class="h-8 w-80">
<button hx:post="@{/contacts/archives}"
class="button-secondary">
Download archive
</button>
</div>
</div>

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:

212 | Chapter 9. Project 2: Contact application


Modern frontends with htmx

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>

① Check if the archiving is currently running. if so we will render a progress bar.


② Use htmx to return the current status of the export. Every 500ms after loading the page, we
retrigger the request so we will be showing an updated progress bar every 500ms.
③ Use the width CSS class to set the width of the progress bar.

④ Handle the complete status.


⑤ Handle the failed status.

Update application.css for the progress bar:

#progress-wrapper {
width: 25%;
}

.progress-container {
height: 20px;
margin-bottom: 20px;

Chapter 9. Project 2: Contact application | 213


Modern frontends with htmx

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;
}

Our HTML will trigger a request to /contacts/archives/<archiveId> every 500 milliseconds, so


we need to implement this endpoint in ContactController:

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.

214 | Chapter 9. Project 2: Contact application


Modern frontends with htmx

Figure 43. Download archive button above the list of contacts

Chapter 9. Project 2: Contact application | 215


Modern frontends with htmx

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.

9.11.3. Download link

Update archive.html to show a download link when the export is completed:

templates/contacts/archive.html

<th:block th:if="${status.name() == 'COMPLETE'}">


<div>
<div class="text-sm">The archive is ready.</div>
<a th:href="@{/contacts/archives/{id}(id=${archiveId})}"
class="text-indigo-400 hover:text-indigo-900 flex gap-1
mt-1">
<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="M9 12.75l3 3m0 0l3-3m-3 3v-7.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Download</a>

216 | Chapter 9. Project 2: Contact application


Modern frontends with htmx

</div>
</th:block>

As explained in Boost the application, you can use hx-boost="true" on the

 <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.

Add the supporting controller method:

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();

ContentDisposition contentDisposition = ContentDisposition


.attachment()
.filename("archive.csv", StandardCharsets.UTF_8)
.build();

response.setContentType("text/csv");
response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
contentDisposition.toString());
response.getOutputStream().write(archive.getBytes(StandardCharsets
.UTF_8));
response.flushBuffer();
}

Chapter 9. Project 2: Contact application | 217


Modern frontends with htmx

Figure 45. Download link when the archive is complete

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, …

218 | Chapter 9. Project 2: Contact application


Modern frontends with htmx

Chapter 10. Web components

10.1. What are web components?


Front-end frameworks like Angular or React allow to build custom components that encapsulate
HTML, CSS and JavaScript. While that is very convenient if you stay within the framework, they cannot
be used outside of the framework.

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.

The Mozilla website defines them as follows:

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

10.2. Integrating Shoelace


Shoelace is a web component library with some really nice components. We will see how we can
integrate it into our project and combine it with htmx.

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

• Project Name: GitHub Tree

• Spring Boot version: 3.1.4

• Live reload: NPM based with Tailwind CSS

• Web dependencies: htmx, shoelace

• Tailwind dependencies: typography

You can see the webjar dependency in the generated pom.xml:

<dependency>

Chapter 10. Web components | 219


Modern frontends with htmx

<groupId>org.webjars.npm</groupId>
<artifactId>shoelace-style__shoelace</artifactId>
<version>2.9.0</version>
</dependency>

The CSS and JS links are added in the src/main/resources/templates/layout/main.html file:

<link rel="stylesheet" th:href="@{/webjars/shoelace-


style__shoelace/dist/themes/light.css}">

<script type="module" th:src="@{/webjars/shoelace-


style__shoelace/cdn/shoelace-autoloader.js}"></script>

10.3. GitHub Tree


The example project will display an expandable tree with the names of GitHub projects for a certain
user. Expanding a project will show all released versions for that project. Selecting a version will
display the release notes of that version in a central panel. The finished application will look like this:

220 | Chapter 10. Web components


Modern frontends with htmx

Figure 46. The finished application showing the release notes of the 4.2.0 release of the Error Handling Spring
Boot Starter project

10.3.1. GitHub API


To list the GitHub projects of a user, we will need to talk to the GitHub API. The GitHub API for Java
library makes this quite straightforward.

Start by adding the dependency:

pom.xml

<dependency>
<groupId>org.kohsuke</groupId>
<artifactId>github-api</artifactId>
<version>1.316</version>
</dependency>

We can now build a GitHubGateway component with 2 methods:

• 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;

Chapter 10. Web components | 221


Modern frontends with htmx

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);
}
}

public List<RepositoryRelease> getRepositoryReleases(String


username, String repositoryName) {
try {
GitHub github = GitHub.connectAnonymously();
GHUser wimdeblauwe = github.getUser(username);
GHRepository repository = wimdeblauwe.getRepository
(repositoryName); ⑤
return repository.listReleases().toList()
.stream()
.map(ghRelease -> new RepositoryRelease(
ghRelease.getId(),
ghRelease.getName()))
.toList(); ⑥
} catch (IOException e) {
throw new RuntimeException(e);
}
}

public record RepositoryRelease(long id, String name) {

222 | Chapter 10. Web components


Modern frontends with htmx

① Connect to GitHub.
② Search for the user with the given username.

③ List all repositories for that user.


④ Extract the name of each repository and return it as a list.
⑤ Get the repository with the given repositoryName.

⑥ 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";

private final GitHubGateway gateway;

public HomeController(GitHubGateway gateway) {


this.gateway = gateway;
}

@GetMapping
public String index(Model model) {
List<String> repositories = gateway.getRepositories(USERNAME);
model.addAttribute("repositories", repositories);
return "index";
}
}

Chapter 10. Web components | 223


Modern frontends with htmx

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.

224 | Chapter 10. Web components


Modern frontends with htmx

③ Set the name of the repository in the tree item.

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.

10.3.2. Make it lazy


When you load the page, it takes a while before anything is displayed because we call the GitHub API
and that takes a while to respond. To have a better user experience, we can lazily load the
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"; ④
}

① Remove calling the gateway at page load.


② New endpoint to get all repositories.
③ Call the gateway to retrieve all repositories for a given username.
④ Return a Thymeleaf fragment that will render all the repositories.

Add a new template at src/main/resources/templates called repositories-tree.html that


will render the 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>

Chapter 10. Web components | 225


Modern frontends with htmx

</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>

① The sl-tree-item element has the following attributes set:


• lazy to enable the lazy loading behavior.

• hx-trigger to trigger the request when the tree item gets expanded.

• hx:get to indicate what endpoint should be called.

• hx-swap to indicate what part of the DOM should be swapped.

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.

226 | Chapter 10. Web components


Modern frontends with htmx

Figure 47. Loading indicator while list of repositories is fetched

10.3.3. Add repository releases


We can now use the same way of working to make each repository tree item itself lazy to load the list
of releases for that repository.

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";
}

Update the repositories-tree.html fragment:

Chapter 10. Web components | 227


Modern frontends with htmx

<!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.

② Set the name of the repository.


③ If there are no releases, add a message saying so and disable the tree item.
④ If there are releases, add a tree item for each release.

The final part of the puzzle is now updating the center of the application with the actual release notes.

10.3.4. Show release notes


We can get the release notes from GitHub, but the notes are written in Markdown. We need to
convert them into HTML to show in our web page.

Add the commonmark library to the application to do this:

<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark</artifactId>
<version>0.20.0</version>

228 | Chapter 10. Web components


Modern frontends with htmx

</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"; ③
}

private String renderMarkdown(String markdown) {


Parser parser = Parser.builder().build();
Node document = parser.parse(markdown);
HtmlRenderer renderer = HtmlRenderer.builder().build();
return renderer.render(document);
}

① Get the release notes from the gateway.


② Add the HTML rendered markdown as the releaseBody attribute to the model.

③ Render the release body in a Thymeleaf template.

The code in the controller depends on a new method in the GitHubGateway:

com.modernfrontendshtmx.githubtree.GitHubGateway

public String getRepositoryRelease(String username,


String repositoryName,
long releaseId) {
try {
GitHub github = GitHub.connectAnonymously();
GHUser wimdeblauwe = github.getUser(username);
GHRepository repository = wimdeblauwe.getRepository
(repositoryName);
return repository.getRelease(releaseId).getBody();
} catch (IOException e) {
throw new RuntimeException(e);
}

Chapter 10. Web components | 229


Modern frontends with htmx

Update the repositories-tree.html:

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 th:fragment="release-body" th:utext="${releaseBody}"> ②

</div>

① The following attributes are set:


• hx-target: The HTML that comes back should be placed in the center release-notes-
container div.
• hx-indicator: Show a progress indicator while the request is in flight.

• hx:get: Endpoint to call when a tree item is selected.

② Use th:utext to avoid that the HTML would be escaped.

Add the loading indicator for when the release notes are retrieved to index.html:

src/main/resources/templates/index.html

<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 id="release-notes-loading-indicator"
class="my-2">
<svg class="htmx-indicator animate-spin ml-3 mb-3 h-5 w-

230 | Chapter 10. Web components


Modern frontends with htmx

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.

Figure 48. Loading indicator while retrieving the release notes

Chapter 10. Web components | 231


Modern frontends with htmx

Figure 49. Formatted release notes

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:

232 | Chapter 10. Web components


Modern frontends with htmx

Figure 50. Shoelace notification triggered from htmx

Chapter 10. Web components | 233


Modern frontends with htmx

Chapter 11. Server-sent events & websockets

11.1. Server-sent events

11.1.1. What are Server-sent events?


Server-sent events (SSE) allow the server to push data towards the client, the browser in our case. It is
a one-way street. Only the server can send data to the client, not vice versa. If you want two-way
communication, you need to use Websockets which is slightly more complex. But for many situations,
SSE is enough, and it’s easier to use.

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.

11.1.2. Using the htmx sse extension

11.1.2.1. Project setup

Use ttcli to generate the following project:

• Group: com.modernfrontendshtmx

• Artifact: ssedemo

• Project Name: SSE Demo

• Spring Boot version: 3.1.4

• Live reload: NPM based with Tailwind CSS

• Web dependencies: htmx, shoelace

• Tailwind dependencies: daisy-ui

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>

The work is happening in FileProcessor:

234 | Chapter 11. Server-sent events & websockets


Modern frontends with htmx

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<>();

public void process(InputStream inputStream) throws IOException { ①


try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
inputStream.transferTo(baos);
try (InputStream firstClone = new ByteArrayInputStream(baos
.toByteArray());
InputStream secondClone = new ByteArrayInputStream(
baos.toByteArray())) {
int numberOfLinesTotal = CharStreams.readLines(new
InputStreamReader(firstClone)).size(); ②
CharStreams.readLines(new InputStreamReader(
secondClone),
new MyLineProcessor(numberOfLinesTotal)); ③
}
}
}

public void addProgressListener(ProgressListener progressListener) {



progressListeners.add(progressListener);
}

public void removeProgressListener(ProgressListener


progressListener) {
progressListeners.remove(progressListener);
}

private static void sleepQuietly() {


try {
Thread.sleep(10);

Chapter 11. Server-sent events & websockets | 235


Modern frontends with htmx

} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}

private class MyLineProcessor implements LineProcessor<Void> { ⑤


private final int numberOfLinesTotal;
private int numberOfLinesProcessed = 0;

private MyLineProcessor(int numberOfLinesTotal) {


this.numberOfLinesTotal = numberOfLinesTotal;
}

@Override
public boolean processLine(String line) throws IOException {
// Fake some import work
sleepQuietly();
numberOfLinesProcessed++;
notifyProgressListeners(line); ⑥
return true;
}

@Override
public Void getResult() {
return null;
}

private void notifyProgressListeners(String line) {


for (ProgressListener progressListener : progressListeners)
{
Progress progress = new Progress(numberOfLinesProcessed
/ (double) numberOfLinesTotal);
progressListener.onProgress(progress, "Processing line :
" + line);
}
}
}
}

① 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.

236 | Chapter 11. Server-sent events & websockets


Modern frontends with htmx

④ Method to add a listener that wants to listen for progress.


⑤ The inner class that is called for each line of the file.
⑥ After each line is processed, the listeners are informed of the current progress.

It exposes its progress through a ProgressListener interface that can be implemented to be


informed of progress as the processing is happening:

package com.modernfrontendshtmx.ssedemo;

public interface ProgressListener {


void onProgress(Progress progress,
String message);
}

This ProgressListener uses a Progress value object:

package com.modernfrontendshtmx.ssedemo;

import org.springframework.util.Assert;

public record Progress(double value) {


public Progress {
Assert.isTrue(value >= 0.0 && value <= 1.0, "Progress value
should be between 0.0 and 1.0");
}
}

11.1.2.2. Upload file

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"

Chapter 11. Server-sent events & websockets | 237


Modern frontends with htmx

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>
</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 form element has the following attributes:


• hx-encoding to have htmx send a multipart request with the selected file.

• hx:post to set the URL to POST to.

• hx-disabled-elt allows to automatically disable elements on the page while a request is in


flight. You can use this to disable the current element, or use a CSS selector to indicate what
elements should be disabled. We use it here to disable the <input> and the <button> in the
form since they both have the disable-during-request CSS class.
• hx-swap is set to none as we don’t want to swap anything. Our controller method is not
returning anything.
② The <input> uses a name of file which will be important to write our controller method.

③ 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;

public HomeController(FileProcessor fileProcessor) { ①

238 | Chapter 11. Server-sent events & websockets


Modern frontends with htmx

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()); ④
}

① Inject the FileProcessor.

② 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.

You should see something like this:

Chapter 11. Server-sent events & websockets | 239


Modern frontends with htmx

Figure 51. File input with import button

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.

11.1.2.3. Show lines being processed

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.

Start by adding the dependency:

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:

240 | Chapter 11. Server-sent events & websockets


Modern frontends with htmx

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;

private final FileProcessor fileProcessor;


private ProgressListener progressListener;

public SseBroker(FileProcessor fileProcessor) { ①


this.fileProcessor = fileProcessor;
eventPublisher = createNewSink(); ②
}

@PostConstruct
void init() {
progressListener = (progress, message) ->
eventPublisher.tryEmitNext(new ProgressEvent(progress,
message)); ③
fileProcessor.addProgressListener(progressListener); ④
}

@PreDestroy
void destroy() {
fileProcessor.removeProgressListener(progressListener); ⑤
}

public Flux<List<ProgressEvent>> subscribeToUpdates() { ⑥


return this.eventPublisher.asFlux()
.buffer(Duration.ofSeconds(1)); ⑦
}

private static Sinks.Many<ProgressEvent> createNewSink() {


return Sinks.many()

Chapter 11. Server-sent events & websockets | 241


Modern frontends with htmx

.multicast()
.onBackpressureBuffer(Queues.SMALL_BUFFER_SIZE, false);
}

public record ProgressEvent(Progress progress, String message) { ⑧


}
}

① Inject the FileProcessor so we can listen for progress.

② Create a sink where we will be able to push objects on to have them stream to a Flux.

③ Define a ProgressListener implementation that emits a new ProgressEvent instance


whenever a progress message arrives.
④ Add the progress listener to the FileProcessor.

⑤ 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));
}

242 | Chapter 11. Server-sent events & websockets


Modern frontends with htmx

private String replaceNewLines(String message) {


return message.replaceAll("\n", "<br>");
}

① Call the subscribeToUpdates() method on the injected SseBroker instance.

② Use the ServerSentEvent.builder() to create a ServerSentEvent instance.

③ 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

private static final Logger LOGGER = LoggerFactory.getLogger


(HomeController.class);

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 class="text-gray-700 mt-4">Progress log:</div>


<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"> ②
</div>
</div>

Chapter 11. Server-sent events & websockets | 243


Modern frontends with htmx

</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>.

③ Load the htmx SSE extension.

If you test, you should see the contents of the file appearing in the log section of the page:

Figure 52. Log section showing each line that is processed

If you check the developer tools, you can see the messages being streamed over the /progress
endpoint:

244 | Chapter 11. Server-sent events & websockets


Modern frontends with htmx

Figure 53. The SSE event stream on 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:

<button hx-on:click="alert('I was clicked')"></button>

This would show the alert for a click event.

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);">

Chapter 11. Server-sent events & websockets | 245


Modern frontends with htmx

</div>

We could use hx-on:htmx:after-settle as well, but hx-on::after-settle (using a double


colon) is a shortcut for htmx events.

11.1.2.4. Show progress bar

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.

Update HomeController for this:

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));
}

private ServerSentEvent<String> createLogEvent(List<ProgressEvent>


events) {
return ServerSentEvent.<String>builder()
.event("log-event") ④
.data(events.stream()
.map(progressEvent -> "<div>%s</div>".formatted
(replaceNewLines(progressEvent.message())))
.collect(Collectors.joining()))

246 | Chapter 11. Server-sent events & websockets


Modern frontends with htmx

.build();
}

private static ServerSentEvent<String> createProgressEvent(List


<ProgressEvent> events) {
return ServerSentEvent.<String>builder()
.event("progress-event") ⑤
.data(events.stream()
.max(Comparator.comparing(progressEvent ->
progressEvent.progress().value())) ⑥
.map(progressEvent -> "<progress class=\"progress w-
full h-6\" value=\"%d\" max=\"100\" ></progress>".
formatted((int) (progressEvent.progress
().value() * 100))) ⑦
.orElse(null)) ⑧
.build();
}

① Use flatMap instead of map as we will create two server-sent events now out of the
List<ProgressEvent> input.

② Flux.just allows creating a Flux from the given objects.

③ Filter out any null server-sent events that got generated.

④ 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.

• progress-event with the HTML of the progress bar.

We can now update index.html to use those events in different ways:

<!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">

Chapter 11. Server-sent events & websockets | 247


Modern frontends with htmx

<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 class="mt-4" sse-swap="progress-event"> ①

</div>

<div class="text-gray-700 mt-4">Progress log:</div>


<div id="progress-log"
class="mt-4 p-2 h-96 font-mono bg-gray-700 text-gray-100
overflow-y-auto"
hx-swap="beforeend"
sse-swap="log-event"
hx-on::after-settle="this.scrollTo(0, this.scrollHeight);">

</div>
</div>
</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>

① 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));

248 | Chapter 11. Server-sent events & websockets


Modern frontends with htmx

Figure 54. Progress bar showing during the processing

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.

11.1.2.5. Check if connection is active

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-

Chapter 11. Server-sent events & websockets | 249


Modern frontends with htmx

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: {}",

250 | Chapter 11. Server-sent events & websockets


Modern frontends with htmx

signalType));
}

private ServerSentEvent<String> createLogEvent(List<ProgressEvent>


events) {
return ServerSentEvent.<String>builder()
.event("log-event")
.data(events.stream()
.map(progressEvent -> "<div>%s</div>".formatted
(replaceNewLines(progressEvent.message())))
.collect(Collectors.joining()))
.build();
}

private static ServerSentEvent<String> createProgressEvent(List


<ProgressEvent> events) {
return ServerSentEvent.<String>builder()
.event("progress-event")
.data(events.stream()
.max(Comparator.comparing(progressEvent ->
progressEvent.progress().value()))
.map(progressEvent -> "<progress class=\"progress w-
full h-6\" value=\"%d\" max=\"100\" ></progress>".
formatted((int) (progressEvent.progress
().value() * 100)))
.orElse(null))
.build();
}

① Generate an endless stream of Long values every given interval.


② Map the long value to a ServerSentEvent.

③ Merge the heartbeat stream with the regular stream.

You can now test this in 2 ways:

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).

Chapter 11. Server-sent events & websockets | 251


Modern frontends with htmx

Figure 55. Warning message if SSE connection is down

This concludes our exploration of the SSE support in htmx. We will look at using Websockets for
bidirectional communication next.

11.2. Websockets

11.2.1. What are websockets?


Websockets (WS) allow to have a bi-directional communication channel between the server and the
client (browser). Remember that Server-sent events only allow one directional push communication
from the server to the browser.

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.

11.2.2. Using the htmx ws extension

11.2.2.1. Project setup

Use ttcli to generate the following project:

• Group: com.modernfrontendshtmx

• Artifact: wsdemo

252 | Chapter 11. Server-sent events & websockets


Modern frontends with htmx

• Project Name: WS Demo

• Spring Boot version: 3.1.4

• Live reload: NPM based with Tailwind CSS

• Web dependencies: htmx

• Tailwind dependencies: daisy-ui

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>

Next, we need to implement a WebSocketHandler. We can base our implementation on


BinaryWebSocketHandler if we want to send low-level bytes over the wire, but for usage with htmx,
we can use TextWebSocketHandler.

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"

Chapter 11. Server-sent events & websockets | 253


Modern frontends with htmx

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>

① Following attributes are important here:


• hx-ext="ws": enable the websockets htmx extension.

• ws-connect="/chatroom": /chatroom is the websocket endpoint of our Spring Boot


application.
• hx-on::ws-after-message="clearInput()": We listen for the htmx:wsAfterMessage
event to clear the input field again after a message was sent over the websocket.
② To send a message from the browser to the server, we add the ws-send attribute to the <form/>.
This will submit the values of the form in JSON format over the websocket.
③ The <input> element allows the user to type a message to send in the chat. The value of name will
be used as key in the JSON of the message.
④ We need to load the websockets extension to get websocket support.
⑤ Helper method that clears the input and puts focus on it again.

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;

254 | Chapter 11. Server-sent events & websockets


Modern frontends with htmx

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 {

private static final Logger LOGGER = LoggerFactory.getLogger


(SocketHandler.class);
private final List<WebSocketSession> sessions = new
CopyOnWriteArrayList<>(); ①

private final ObjectMapper objectMapper;

public SocketHandler(ObjectMapper objectMapper) { ②


this.objectMapper = objectMapper;
}

@Override
public void handleTextMessage(WebSocketSession session,
TextMessage message)
throws IOException {

Map<String, Object> value = objectMapper.readValue(message


.getPayload(), new TypeReference<>() {
}); ③
String userMessage = (String) value.get("chatMessage"); ④
sendMessageToWebSocketsessions(userMessage); ⑤
}

@Override
public void afterConnectionEstablished(WebSocketSession session) {
sessions.add(session);
}

@Override
public void afterConnectionClosed(WebSocketSession session,

Chapter 11. Server-sent events & websockets | 255


Modern frontends with htmx

CloseStatus status) throws Exception {


sessions.remove(session);
}

private void sendMessageToWebSocketsessions(String message) {


for (WebSocketSession webSocketSession : sessions) { ⑥
try {
if (webSocketSession.isOpen()) {
webSocketSession.sendMessage(new TextMessage(
message)); ⑦
}
} catch (IOException e) {
LOGGER.debug("Unable to send message to {}",
webSocketSession);
}
}
}
}

① Keep track of the connected clients.


② Inject the Spring Boot global ObjectMapper to deserialize the received JSON message.

③ Convert the JSON into a Map.

④ 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 { ②

256 | Chapter 11. Server-sent events & websockets


Modern frontends with htmx

private final SocketHandler socketHandler;

public WebSocketConfig(SocketHandler socketHandler) { ③


this.socketHandler = socketHandler;
}

public void registerWebSocketHandlers(WebSocketHandlerRegistry


registry) {
registry.addHandler(socketHandler, "/chatroom"); ④
}
}

① Enable support for websockets.


② Implement the WebSocketConfigurer interface.

③ Inject our SocketHandler we created above.

④ Register the SocketHandler instance under the /chatroom URL.

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.

Chapter 11. Server-sent events & websockets | 257


Modern frontends with htmx

Figure 56. Messages sent and received over a websocket connection

We can now update sending some HTML back, so we have a real chat client application.

11.2.2.2. Send HTML back using Thymeleaf templates

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.

Update index.html with these 2 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>

258 | Chapter 11. Server-sent events & websockets


Modern frontends with htmx

<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.

To render the Thymeleaf fragments in SocketHandler, we need to inject SpringTemplateEngine


and use the process(…) method:

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 {

Chapter 11. Server-sent events & websockets | 259


Modern frontends with htmx

private static final Logger LOGGER = LoggerFactory.getLogger


(SocketHandler.class);
private final List<WebSocketSession> sessions = new
CopyOnWriteArrayList<>();

private final ObjectMapper objectMapper;


private final SpringTemplateEngine templateEngine;

public SocketHandler(ObjectMapper objectMapper,


SpringTemplateEngine templateEngine) { ①
this.objectMapper = objectMapper;
this.templateEngine = templateEngine;
}

@Override
public void handleTextMessage(WebSocketSession session,
TextMessage message)
throws IOException {

Map<String, Object> value = objectMapper.readValue(message


.getPayload(), new TypeReference<>() {
});
String userMessage = (String) value.get("chatMessage");
sendMessageToWebSocketsessions(session, userMessage); ②
}

@Override
public void afterConnectionEstablished(WebSocketSession session) {
sessions.add(session);
}

@Override
public void afterConnectionClosed(WebSocketSession session,
CloseStatus status) throws Exception {
sessions.remove(session);
}

private void sendMessageToWebSocketsessions(WebSocketSession


currentSession,
String message) {
for (WebSocketSession webSocketSession : sessions) {
try {
if (!webSocketSession.isOpen()) {
continue;
}

260 | Chapter 11. Server-sent events & websockets


Modern frontends with htmx

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);
}
}
}

private String userMessageHtml(String message) { ④


Context context = new Context(null, Map.of("message", message));

return templateEngine.process("index", Set.of("user-message"),
context); ⑥
}

private String incomingMessageHtml(String message) {


Context context = new Context(null, Map.of("message", message));
return templateEngine.process("index", Set.of("incoming-
message"), context);
}
}

① Inject the SpringTemplateEngine.

② Pass the WebSocketSession that is currently sending the message.

③ 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").

Chapter 11. Server-sent events & websockets | 261


Modern frontends with htmx

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.

262 | Chapter 11. Server-sent events & websockets


Modern frontends with htmx

Chapter 12. Closing


This is the end of the Modern frontends with htmx book. I hope you enjoyed the explanation and
examples in the book. At the very least, they should give you a feeling of what htmx can do, and
hopefully, it also showed how to implement what you want to build in a clear way.

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

Chapter 12. Closing | 263


Modern frontends with htmx

Appendix A: Change log

1.0.0
December 3rd, 2023

• Initial release

264 | Appendix A: Change log

You might also like