I'm serious.
Short answer, frontend isn't complex, just got too complex solutions. With htmx, we use a solution for the frontend and get the same result, but with less complexity.
The key argument behind htmx is to augment hypertext documents (a.k.a. HTML) in a way that they become fully capable of fulfill, in a declarative way, the Hyper Text Transfer Protocol, best known as HTTP.
Classic HTML interacts with other HTTP resources either via anchor links, formularies or rely on explicit javascript code. Only via javascript is possible to use all verbs described in the protocol.
So, htmx changes that and make all HTTP verbs available for html documents as attributes, and behaviors that where only possible with javascript imperative code as well, like update only a small part of the document.
The hello world is pretty straightforward:
<button hx-post="/clicked"
hx-trigger="click"
hx-target="#parent-div"
hx-swap="outerHTML">
Click Me!
</button>
This triggers a post request to a /clicked
endpoint when the html button is
clicked and replaces the element which id is parent-div
.
Yes, there is javascript behind the scenes, but the entire behavior is coded in a declarative way. It changes everything.
In the beginning, server where responsible for create dynamic content and served the resulting document to the browser. Life was simple.
Then dynamic hypermedia became a thing and they called it AJAX. Partial updates in a document shifted how we interacted with the internet forever.
The growing complexity drifted the hypertext nature from document to application and that demanded new technologies able to handle those needs. Web applications became more and more self-sufficient, demanding less rendering efforts from servers, engaging more sophisticated build systems. The SPA became the standard for web apps and MVVM was the design pattern to apply everywhere.
Application performance became a big concern and new technologies emerged to solve this. There was also the JAMSTACK age, where some original characteristics from hypertext, like multiple documents and proper SEO, got attention. Frontend development grew even more complex.
Then server side rendering, server components and islands connected the complex frontend stacks into servers again, bringing back the role of user interface construction back to server-side. But it got completely integrated with those modern front-end tools.
This is the scenario where htmx emerges. It does exactly the same what those state of the art, vanguard frontend technologies does, but ditches off complexities like bundle building, tree-shaking unused dependencies and very convoluted ways of transform JSON data into a dynamic document.
One could say that current modern REST APIs are, in fact, JSON APIs.
REST is supposed to be built on top of hypertext, so the claim holds.
Not quite.
We do need at least one server, but it doesn't need to be specific to htmx.
For example, we can write a kotlin web application using a decent web framework and it does not need to know nothing specific to htmx. All it needs to know is how to respond requests with html.
In the example bellow we wire the routes for a simple todo app:
package sample.htmx
import io.javalin.Javalin
import io.javalin.apibuilder.ApiBuilder.*
import io.javalin.rendering.template.JavalinVelocity
import org.slf4j.LoggerFactory
import sample.htmx.config.Database
import sample.htmx.controller.TodoController
class App(
val controller: TodoController = TodoController(),
val javalin: Javalin = Javalin.create { config ->
config.fileRenderer(JavalinVelocity())
config.staticFiles.enableWebjars()
config.router.apiBuilder {
get("/", controller::index)
path("/todos") {
get(controller::list)
post(controller::insert)
path("/{id}") {
get(controller::find)
put(controller::update)
delete(controller::delete)
}
}
}
}
) {
private val logger by lazy { LoggerFactory.getLogger(App::class.java) }
fun start(port: Int = 8080) {
logger.info("start app on port $port")
javalin.start(port)
}
}
fun main() {
Database.init()
val app = App()
app.start()
}
The JavalinVelocity
renderer knows how to render pages of fragment pages using
a very old, reliable, classic template engine while TodoController
does what a classic controller from MVC design pattern is supposed to do:
package sample.htmx.controller
import io.javalin.http.Context
import org.slf4j.LoggerFactory
import sample.htmx.model.TodoItem
import sample.htmx.service.TodoService
class TodoController(val service: TodoService = TodoService()) {
private val logger by lazy { LoggerFactory.getLogger(TodoController::class.java) }
fun index(ctx: Context): Context {
logger.info("index")
val todos = service.list()
return ctx.render("/templates/velocity/index.vm", mapOf("todos" to todos))
}
fun list(ctx: Context): Context {
logger.info("list")
val todos = service.list(ctx.queryParam("q"))
return ctx.render("/templates/velocity/todos/list.vm", mapOf("todos" to todos))
}
fun find(ctx: Context): Context {
logger.info("find")
val id = ctx.pathParam("id").toLong()
val todo = service.find(id)
return ctx.render("/templates/velocity/todos/detail.vm", mapOf("todo" to todo))
}
fun insert(ctx: Context): Context {
logger.info("insert")
val todo = TodoItem(description = ctx.formParam("description").toString())
service.insert(todo)
val todos = service.list()
return ctx.render("/templates/velocity/todos/list.vm", mapOf("todos" to todos))
}
fun update(ctx: Context): Context {
logger.info("update")
val todo = TodoItem(
description = ctx.formParam("description").toString(),
done = ctx.formParam("done").toBoolean()
)
val id = ctx.pathParam("id").toLong()
todo.id = id
service.update(todo)
return ctx.render("/templates/velocity/todos/list.vm", mapOf("todos" to service.list()))
}
fun delete(ctx: Context): Context {
logger.info("delete")
val id = ctx.pathParam("id").toLong()
service.delete(id)
return ctx.render("/templates/velocity/todos/list.vm", mapOf("todos" to service.list()))
}
}
The controller checks data from service, do some validation and then renders the response.
One key difference here is the result: unlike traditional services around there, the result isn't a json or xml: it's a html fragment dynamically produced by a velocity template.
This is the index.vm
template, to show a sample:
<!DOCTYPE html>
<html>
<head>
<title>Sample Javalin with HTMX</title>
<script src="/webjars/htmx.org/2.0.0-alpha1/dist/htmx.js"></script>
<script>
htmx.logAll();
</script>
</head>
<body>
<h1>TODO List</h1>
#parse("/templates/velocity/todos/form.vm")
#parse("/templates/velocity/todos/list.vm")
</body>
</html>
It does basic htmx bootstrap and uses other templates to compose itself.
List template shows a button used to update item:
<table id="table">
<tr>
<th>#</th>
<th>Description</th>
<th>Done?</th>
<th></th>
</tr>
#foreach($todo in $todos)
<tr>
<td>$todo.id <input class="edit$todo.id" type="hidden" value="$todo.id"/></td>
<td><input class="edit$todo.id" type="text" name="description" value="$todo.description"/></td>
<td>
<!-- TODO try to use checkboxes again -->
<select class="edit$todo.id" name="done">
<option #if($todo.done) selected #end>true</option>
<option #if(!$todo.done) selected #end>false</option>
</select>
</td>
<td>
<button hx-put="/todos/$todo.id" hx-swap="outerHTML"
hx-target="#table" hx-include=".edit$todo.id">Save
</button>
</td>
</tr>
#end
</table>
The button performs a PUT request to server to update the item knowing its id.
the hx-target
orients htmx to place the result of this request as a
replacement for the entire table and hx-include
collects all field values we
want to be present in this request.
Since htmx augments html, we're not tied anymore to either use javascript directly to perform a request or to create a form and make either GET or POST request. Besides that, rules remains the same, we send the name/value pair of the element in the request.
Nothing really special on this server regarding htmx. Due it's declarative nature, any template language will cope well with render it properly.
Our server uses Jdbi to query a h2 database and uses two testing frameworks, JUnit and Spock.
The Database configuration shows how simple is to interact with the database using Jdbi:
package sample.htmx.config
import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource
import io.github.cdimascio.dotenv.Dotenv
import org.jdbi.v3.core.Jdbi
import javax.sql.DataSource
class Database {
companion object {
private val dotenv by lazy {
Dotenv.configure().load()
}
private val config by lazy {
HikariConfig(dotenv.get("DATASOURCE_PROPERTIES"))
}
private val dataSource: DataSource by lazy {
HikariDataSource(config)
}
val jdbi: Jdbi by lazy {
Jdbi.create(dataSource)
}
@JvmStatic
fun init() {
jdbi.withHandle<Any, Exception> { handle ->
handle.execute("""
create table if not exists todos(
id integer primary key auto_increment,
description text not null,
done boolean default false,
created timestamp default now()
);
""".trimIndent())
}
}
@JvmStatic
fun testSeed() {
jdbi.withHandle<Any, Exception> { handle ->
handle.execute("""
insert into todos (done, description) values (true,'laundry');
insert into todos (done, description) values (false,'lunch');
insert into todos (done, description) values (false,'exercise');
""".trimIndent())
}
}
}
}
The SQL queries are executed inside lambdas.
Javalin has a very handy test helper which allow us to test requests in a very clear way:
package sample.htmx
import io.javalin.testtools.JavalinTest
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import sample.htmx.config.Database
import sample.htmx.model.TodoItem
class ApiTest {
private val app = App()
private lateinit var todos: List<TodoItem>
@BeforeEach
fun setup() {
Database.init()
Database.testSeed()
todos = app.controller.service.list()
}
@Test
fun `Should check TodoItem endpoints`() = JavalinTest.test(app.javalin) { server, client ->
// basic GET endpoints
Assertions.assertEquals(200, client.get("/").code)
Assertions.assertEquals(200, client.get("/todos").code)
Assertions.assertEquals(200, client.get("/todos/${todos.first().id}").code)
// new/modify TodoItem
Assertions.assertEquals(200, client.post("/todos", "description=new todo").code)
Assertions.assertEquals(200, client.put("/todos/${todos.first().id}", "description=update todo").code)
// remove
Assertions.assertEquals(200, client.delete("/todos/${todos.first().id}").code)
}
}
Spock framework has a neat assertion style and other goodies that will be explored in future writings:
package sample.htmx
import java.time.LocalDateTime
import sample.htmx.config.Database
import sample.htmx.model.TodoItem
import sample.htmx.service.TodoService
import spock.lang.Shared
import spock.lang.Specification
class ServiceTest extends Specification {
@Shared
def service = new TodoService()
def setup() {
Database.init()
}
def "Should list todos"() {
expect:
service.list("") != null
}
def "should insert todo"() {
given:
def result = service.insert(new TodoItem())
when:
def check = service.list("")
then:
check != null
check.size() > 0
}
def "should update todo"() {
given:
def id = service.insert(new TodoItem())
when:
def result = service.update(new TodoItem(id, "updated", true, LocalDateTime.now()))
def check = service.list("updated")
then:
check != null
check.size() > 0
}
def "should delete todo"() {
given:
def id = service.insert(new TodoItem())
when:
def result = service.delete(id)
def check = service.find(id)
then:
thrown(Exception)
}
}
Finally, it uses JaCoCo to produce coverage reports during CI.
Whenever facing a problem hard to solve, identify a simpler problem with same results and solve it.
That way you solved the hard problem too.
Current frontend scenario does partial updates and try as much as possible mimetize lost things that always have been present in vanilla web technologies. With htmx those partial updates are simple and natural thanks to html augmentation and lost things are back because the extra layers from current frontend solutions simple are not needed and aren't there.
It is a game-changer.
Also, i chose the more arbitrary possible stack to integrate with htmx and ran into no issue that couldn't be solved by simply reading the docs. That helps to endorse a safe adoption of any technology.
This article is a bit opinionated but the presented tech works and works well. Talk is cheap, show me the code, one could say.
If you have a chance, try htmx.
Happy hacking!