I recently stumbled upon htmx. It's a javascript library allows you to run ajax requests, server side events and websockets directly from your html. No extra JavaScript needed, clean and to the point. Why do I think it's one of the coolest things I've seen lately?
For one, at the moment most of my dev work involves building small apps, prototypes and automating processes. I love anything that reduces the amount of work on the front end.
Beyond that, I take care of running a coding bootcamp in Singapore. My students, who arrive with 0 web development knowledge, often struggle with understanding AJAX. Any tool that eases the learning process is more than welcome.
Thus, I decided to write a get-started tutorial about htmx.
About htmx
htmx is the heir to intercooler.js. It uses html attributes to trigger AJAX calls. You can use the attributes to target an element in the dom, and replace it with the html that comes back in your response. The library supports websockets and server-side events too.
It's beautiful simplicity. Basically you can write html like this
<form class="nes-select is-dark">
<!-- have the selection form send a value via AJAX -->
<select hx-get="/fighters"
name="fighter"
hx-target="#fighter-avatar"
hx-swap="outerHTML">
<option selected> -- select a fighter -- </option>
<option value="ryu">Ryu</option>
<option value="sagat">Sagat</option>
<option value="chunli"> Chun-Li </option>
</select>
</form>
And start building something like this. No page reloads, no additional JS.
htmx does not bring the sophistication of React or Vue, and it doesn't have to. The library excels at reducing the amount of moving pieces when implementing common UI patterns. There's excellent examples in their documentation.
Wait, what's AJAX?
AJAX stands for Asynchronous JavaScript and XML. It's a technique for creating dynamic webpages. The client and the server can exchange data in the background, and the browser's javascript can parse what the server sends to update the website without page reloads.
Features like infinite page scrolls or immediate search suggestions are powered by AJAX. The cool thing about htmx is that we can implement these features writing any javascript ourselves.
A to-do list for Ryu
Here's a tutorial on how to build a yet-another-crud-to-do app with htmx. Sinatra will work as our backend and nes.css will help us with styling. By the end of the walkthrough, we'll end up with something like this.
If you are familiar with the stack and want to jump directly to the htmx code examples, skip to the second feature right away or get the code. Take it from here if you want to build it step by step.
Setup
1. Install Sinatra
Sinatra is a lightweight web application framework written in Ruby that wraps all the goodness of Rack middleware into a convenient DSL. It's super handy for building small apps and APIs. Assuming you have Ruby and Bundler installed, start a new project folder and type the following in your terminal.
bundle init
open your Gemfile and add:
gem "sinatra"
and install the gem with:
bundle install
Now, let's create an app.rb
file where we will keep the app and routing logic, and a folder named public where we will keep our css ( Sinatra uses this folder to serve assets by default )
# in your project folder
touch app.rb && mkdir public
In the app file, we'll require sinatra and write our first route
# app.rb
require 'sinatra'
get '/' do
"Welcome to your app!"
end
Now run:
ruby app.rb
That will run the app on port 4567 in your localhost. Go to your favorite browser and type http://localhost:4567
. You should see something like this.
2. Install htmx
For now, we are going to import htmx directly in our main page. First, let's create a folder named views (sinatra uses ./views
as a default for rendering templates) and touch a file called index.erb
inside
mkdir views
touch views/index.erb
erb (embedded Ruby) allows us to write Ruby into our html. Our index.erb
file work as a template where we will inject other html dynamically.
Within you index file, start an html skeleton and add the htmx llibrary in the document head.
<!-- views/index.erb -->
<html>
<head>
<meta charset="UTF-8">
<title>htmx + sinatra ftw</title>
<script src="https://unpkg.com/htmx.org@0.0.4"></script>
</head>
<body>
</body>
</html>
3. Set up the Front End
We'll use nes.css, an 8-bit themed library to style our app. Add it on the head of your index.erb file. Also, create a style.css file in your public folder.
touch public/style.css
<!-- views/index.erb -->
<html>
<head>
<meta charset="UTF-8">
<title>htmx + sinatra ftw</title>
<script src="https://unpkg.com/htmx.org@0.0.4"></script>
<link href="https://unpkg.com/nes.css@latest/css/nes.min.css" rel="stylesheet">
<link rel="stylesheet" href="./style.css">
</head>
</body>
</body>
</div>
~~<~~/html>
The tutorial is focused on the interaction of between htmx and sinatra, so I won't be covering the css in depth. Copy the code on this gist and paste it on your style.css
file. On your html, make sure you are importing the style.css
after the nes.css library. You can go here to have an overview of how the css components work.
We're all set up, let's go into building the app!
Spec 1: Displaying Tasks
1. Create a Task Model
We need to represent the notion of a task. Let's keep it really simple:
- A task has a description
- A task can be done or not.
- A new task should not be done by default.
- We can mark a task as done
- We can check if it's done or not.
In a file called task.rb
we'll create a Task
class that benefits from ruby Structs to get the job done
touch task.rb
# task.rb
class Task < Struct.new(:description, :done)
def initialize(description, done = false)
super
end
def mark_as_done!
self.done = true
end
def done?
done
end
end
2. Create a Task Repository
There needs to be a place where we keep our tasks and manage them. The task repository will do exactly that. First, create a task_repo.rb
file.
For now, we'll start off the class with a couple of tasks already so we can show them on the index page. We will also set an #all
method that allows us to access the tasks. A #seed
method called on initialization will set a couple of tasks in the list for us already.
touch task_repo.rb
# task_repo.rb
require_relative 'task'
class TaskRepo
def initialize
@tasks = []
seed
end
def all
@tasks
end
private
def seed
@tasks << Task.new("Meet Ken") << Task.new("Practice my hadouken")
end
end
Now, we have two jobs to do. First, getting access to the repository that we just created, and then passing the tasks over to a template. Go to the app.rb
file and add the following before your first get route:
#app.rb
require_relative 'task_repo'
class Sinatra::Application
def initialize
@tasks = TaskRepo.new
super
end
end
get '/' do
This little trick will make a new instance of our repository available across the Sinatra App, keeping our tasks alive as long as the server runs.
4. Create the tasks list
Add the following lines to your app.rb file. They will make the get route to 'localhost:4567' render the tasks file into index.erb file we wrote before.
#app.rb
#...
get '/' do
erb :tasks, layout: :index
end
Now, let's flesh out the view. We need to iterate through the tasks on the repo, and display their descriptions. Create a tasks.erb file in your views folder and add the following:
touch views/tasks.erb
<!-- views/tasks.erb -->
<ul class="nes-list is-disc">
<% @tasks.all.each do |task| %>
<li> </p> <%= task.description %> </p>
<% end %>
</ul>
The <% %>
tags that you see are the syntax that allow you to use ruby code within html. The subtle differences are:
- Whenever we wrap code in
<% %>
, we execute it as ruby code, but we don't render it on the page - When we use
<%= %>
, we render the ruby code that sits inside the tags.
So with the snippet above we are iterating through the tasks, and rendering each of the tasks within an unordered list.
Now we, can adjust the index.erb
layout so the tasks show up.
<!-- views/index.erb -->
<body>
<div class="nes-container main">
<div class="list">
<div class="list-title">
<h2> Ryu's to-do list </h2>
<img class="ryu" src="./ryu_8_bit.png" alt="ryu">
<!-- you might have to source your own ryu image -->
</div>
<%= yield %>
</div>
</body>
The keyword yield
is what Ruby uses to call blocks that have been passed to a method. In this context, yield will call the template that we are choosing on the get '/' route and render it.
When you run your server with ruby app.rb
on your terminal, you should be be able to see the tasks.
Spec 2: Adding Tasks
For now it's all been static. Let's spice things up with htmx.
We can start by creating a form where we can write tasks and a button that allows us to submit them. Add the following after to your .list
element in index.erb
, but still within .nes-container
<!-- views/index.erb -->
<div class="nes-container main">
<div class="list">
....
</div>
<form class="nes-field">
<input class="nes-input" id="task-form" name="task" type="text" placeholder="new task"/>
** <button hx-post="/tasks"
hx-include="input[name='task']"
hx-target=".nes-list"
hx-swap="outerHTML"
class="nes-btn is-primary">
Add Task
</button>
</form>
</div>
Now, check out all the hx-
attributes on the button. Whenever click the button is clicked, the following will happen:
- Send a post request to
/tasks
- In the parameters of the post request, include the value in the input with the name 'task'. ( You can pass any css selector into
hx-include
) - Inject the response into the element with the class
.nes-list
, which is in our tasks template. -
hx-swap="outerHTML"
indicates that the whole element should be replaced
And all that interactivity beautifully expressed in 4 lines of markup. There's no need to declare the click event, since requests are triggered by the "natural" event of an element.
Now, if you try that out, you will get a 404 error. We need Sinatra to handle the request and send html back. Go to app.rb
and add the following.
#app.rb
post '/tasks' do
@tasks.all << Task.new(params['task'])
erb :tasks
end
This will refresh our tasks with the new addition. The good news is that now we can add new tasks via AJAX. The bad news is that we can also add empty tasks, and our form stays filled after submitting the task.
Spec 3: Form Cleaning and Simple Validation
Time to get something more proper. First, we need to make sure the user actually sent a task. Change your post route for the following.
#app.rb
post '/tasks' do
if params['task'].length.positive?
@clear_form = true
@tasks.all << Task.new(params['task'])
else
@error = "please enter a task"
end
erb :tasks
end
So, if we have a task in the params, we will set a flag to clear the form in the view, and we'll create a new task. Otherwise, we'll pass an error message to the view. Let's replace the markup in our view/tasks.erb
file for the following.
<!-- views/tasks.erb -->
<% if @clear_form %>
<input class="nes-input form-error" id="task-form" hx-swap-oob="true" name="task" type="text" placeholder="new task">
<% end %>
<ul class="nes-list is-disc">
<% @tasks.all.each do |task| %>
<li> <p class="nes-text"> <%= task.description %> </p> </li>
<% end %>
</ul>
The hx-swap-oob
attribute means out-of-box swap. It allows you to do html swaps outside of the target we declared on our form, which was .nes-list
. It's important that you don't nest elements with hx-swap-oob
attributes if you want them to work.
It will target the element with the id #task-form
and replace it, while the rest of the html will be replacing the old .nes-list
element. We now get an updated list of tasks plus a fresh form.
Now, the unhappy path. Inside your .nes-list
element, add the following:
<!-- views/tasks.erb -->
<ul class="nes-list is-disc">
<% if @error %>
<p class="nes-text is-error"> <%= @error %> </p>
<% end %>
....
</ul>
This should render the same to do list with a validation error message. The app now should be in this state.
Spec 4: Marking a Task as Done
To check off tasks, we need some way of interacting with our task list. What about inserting a span that, when clicked, sends a request for marking a given task as done? With htmx we can do something like this.
<!-- heads up, this won't work in our app -->
<ul class="nes-list">
<% @tasks.all.each_with_index do |task, index| %>
<li> <%= task.description %>
<span hx-patch=<%= "task/#{index}" %> mark as done </span>
</li>
<% end %>
</ul>
Unfortunately, our Sinatra backend will struggle with parsing this request. It will not recognize the patch
value natively, since many browsers only support get
and post
.
We need a workaround to override the post
method - We can include _method
as a key in the params and set "patch"
as the value, which Sinatra will recognise. Frameworks like Rails also use this technique.
Thus, the view could go as follows:
- When the task is not done, give the user the abilty to mark it as done.
- When the task is done, let the user know with a visual cue
Let's add the following into our iterator:
<!-- views/tasks.erb -->
<ul class="nes-list">
....
<% @tasks.all.each_with_index do |task, index| %>
<% if task.done? %>
<li>
<p class="nes-text is-disabled"> <%= task.description %> </p>
</li>
<% else %>
<li>
<p class="nes-text"> <%= task.description %>
<span class="nes-text is-primary"
hx-post=<%= "tasks/#{index}"%>
hx-target=".nes-list"
hx-swap="outerHTML"
hx-include=<%="[name=_method][data-mark='#{index}']"%>> done
</span>
<input type="hidden" name="_method" value="patch" data-mark=<%="#{index}" %>>
</p>
</li>
<% end %>
<% end %>
</ul>
By doing so, now Sinatra can recognise the patch action, and assign it to the right to-do via the index on the array. We'll use the task id as the reference to know which task is the one to check.
patch '/tasks/:id' do
@tasks.all[params[:id].to_i].mark_as_done!
erb :tasks
end
By now, we can mark tasks as done.
Spec 5: Deleting a Task
Finally, let's close the tour on htmx with deleting. We'll use the same approach we had on marking tasks as done. In the same vein, we need to include an element that affords the delete action. We'll add a small 'X' icon next to the tasks that have been done to allow deletion.
<!-- views/tasks.erb -->
<ul class="nes-list is-disc">
....
<% @tasks.all.each_with_index do |task, index| %>
<% if task.done? %>
<li>
<p class="nes-text is-disabled"> <%= task.description %>
<i hx-post=<%= "tasks/#{index}"%>
hx-target=".nes-list"
hx-swap="outerHTML"
class="nes-icon close is-small"
hx-include=<%= "[name=_method][data-delete='#{index}']" %>>
</i>
</p>
<input type="hidden" name="_method" value="delete" data-delete=<%= "#{index}" %>>
</li>
<% else %>
<!-- view logic for marking task as done -->
....
<% end %>
<% end %>
</ul>
And on Sinatra, you need a route for deleting
delete '/tasks/:id' do
@tasks.all.delete_at(params[:id].to_i)
erb :tasks
end
Now, if the task has not been done, we can mark it as done. If the task is done, we can delete it from the list. Ryu's to-do app should now look like this
Thanks for reading!
htmx allows you to implement modern UI patterns with simple, straightforward html. I truly hope that the library continues to evolve and grow. Take a look at their documentation over here. If you feel like playing around with the app, you can get the code here. A few things you could try out with htmx to take the app further:
- Add a button to delete all tasks
- Make a better validation, now you could add a task made of spaces!
- Disable the
.task-form
button by default, and only enable it when there's text on the input - Fade in the tasks when they show on screen
I’m Miguel and I take care of Product and Growth for Le Wagon in Singapore 🇸🇬. Our immersive coding bootcamps build the tech skills and product mindset you need to kick-start your tech career, upskill in your current job, or launch your own startup.
If you want to exchange thoughts on how to create outstanding learning experiences in tech feel free to contact me at miguel.jimenez@lewagon.org.
Top comments (1)
Miguel,
thank you for this... so many Sinatra tutorials are either incomplete or badly written (missing required commands along the way) and this is the first one that worked for me.
I'd recommend somehow showing ALL the code at each step - highlighting in the new code... but hey, I GOT TO THE END! THANKS!