DEV Community

Miguel Jimenez
Miguel Jimenez

Posted on

Learning AJAX made easy - Build an app with htmx

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>
Enter fullscreen mode Exit fullscreen mode

And start building something like this. No page reloads, no additional JS.

streetfigtherdemo


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.

ajax-explained

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.

complete

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
Enter fullscreen mode Exit fullscreen mode

open your Gemfile and add:

gem "sinatra"
Enter fullscreen mode Exit fullscreen mode

and install the gem with:

bundle install
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

In the app file, we'll require sinatra and write our first route

# app.rb

require 'sinatra'

get '/' do
    "Welcome to your app!"
end
Enter fullscreen mode Exit fullscreen mode

Now run:

ruby app.rb
Enter fullscreen mode Exit fullscreen mode

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.

welcome

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
<!-- 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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
<!-- views/tasks.erb --> 

<ul class="nes-list is-disc">
  <% @tasks.all.each do |task| %>
    <li> </p> <%= task.description %> </p>
  <% end %>
</ul>

Enter fullscreen mode Exit fullscreen mode

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> 
Enter fullscreen mode Exit fullscreen mode

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.

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

add-to-do

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

This should render the same to do list with a validation error message. The app now should be in this state.

validation

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>
Enter fullscreen mode Exit fullscreen mode

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 _methodas 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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

By now, we can mark tasks as done.

mark-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>
Enter fullscreen mode Exit fullscreen mode

And on Sinatra, you need a route for deleting

delete '/tasks/:id' do
  @tasks.all.delete_at(params[:id].to_i)
  erb :tasks
end
Enter fullscreen mode Exit fullscreen mode

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

complete

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.

Latest comments (0)