DEV Community

Cover image for HTMX + Rails: Exploring them with a Toy App.
Joseph Palmezano
Joseph Palmezano

Posted on • Edited on

HTMX + Rails: Exploring them with a Toy App.

📝 Introduction

When it comes to UI, React is king, but in the Rails world We have Turbo to save us from writing as much Javascript code as possible. 😜. Well, HTMX has that same goal and on top of that is THE trend right now. In this article we will take the first steps in HTMX(from Rails perspective) with the help of a modest Google Keep clone.

🚀 Installing HTMX

1. Install the rails-htmx gem and add it to the application's Gemfile:

bundle add rails-htmx
Enter fullscreen mode Exit fullscreen mode

2. Installing the dependency:
Pin the dependency to the importmap:

bin/importmap pin htmx.org
Enter fullscreen mode Exit fullscreen mode

and then import it on your app/javascript/application.js:

import "htmx.org"
Enter fullscreen mode Exit fullscreen mode

3. The response-targets Extension:
Any real-world application will need this extension to handle non 200 http responses. This topic will be addressed later, for now take my word for it and create a app/javascript/htmx.js file, something like this will do:

import htmx from 'htmx.org';
window.htmx = htmx; // Makes htmx available globally.
Enter fullscreen mode Exit fullscreen mode

Then add the extension in your app/javascript/application.js

import './htmx.js'; // We don't need `import "htmx.org"` anymore.
import "htmx.org/dist/ext/response-targets"
Enter fullscreen mode Exit fullscreen mode

🛠️ Basic configuration

1. Add the CSRF Token to the htmx requests:

<!--
Add the X-CSRF-Token to the hx-headers attributes,
this way the X-CSRF-Token is added in all XHR requests done by htmx.
-->
<body hx-headers='{"X-CSRF-Token": "<%= form_authenticity_token %>"}'>
Enter fullscreen mode Exit fullscreen mode

🪁 The Toy App

The code below comes from a Toy App i built with the purpose of getting into HTMX. Check the Repo to get the whole picture.

🎢 Road to CRUD

0. Starting point: views/notes/_form.html.erb

  <%= form_with model: note, url: false do |form| %>
    <!-- form fields -->
  <% end %>
Enter fullscreen mode Exit fullscreen mode

1. AJAX

The core of htmx is a set of attributes that allow you to perform AJAX requests directly from HTML: hx-get, hx-post, hx-put, hx-patch, hx-delete.

  <%= form_with model: note, url: false, data: hx(post: url_for(note))  do |form| %>
    <!-- form fields -->
  <% end %>
Enter fullscreen mode Exit fullscreen mode

Thanks to gem rails-htmx we have at our disposal the hx helper wich we can use to easily generate HTMX attributes in our views. This: hx(post: url_for(note)) will become this: data-hx-post="/note.

2. Targets

Allows the response to be loaded into a different element other than the one that made the request.

 <%= form_with model: note, url: false, data: hx(post: url_for(note), target: "#notes-masonry")  do |form| %>
   <!-- form fields -->
 <% end %>
Enter fullscreen mode Exit fullscreen mode

We use the # CSS Selector to specify the element on wich the Controller's response should be rendered.

2. Swapping

HTMX offers several ways to render the HTML response from the Controller.

 <%= form_with model: note, url: false, data: hx(post: url_for(note), target: "#notes-masonry", swap: "beforeend")  do |form| %>
   <!-- form fields -->
 <% end %>
Enter fullscreen mode Exit fullscreen mode

We're using beforeend wich appends the content after the last child inside the target.

3. Handling errors

By default, non-200 responses are not swapped into the DOM, we need to set this up:

const validStatusCodes = [200, 201, 303];

document.body.addEventListener('htmx:beforeSwap', function(evt) {
  if (!validStatusCodes.includes(evt.detail.xhr.status)) {
    evt.detail.shouldSwap = true;
    evt.detail.target = htmx.find("#form-errors"); // Set target for errors.
    evt.detail.isError = false; // Turn off error logging in console.
  }
});
Enter fullscreen mode Exit fullscreen mode

And finally:

 <div id="errors-alert"></div>
 <%= form_with model: note, url: false, data: hx(post: url_for(note), target: "#notes-masonry", swap: "beforeend", target_error: "#errors-alert")  do |form| %>
   <!-- form fields -->
 <% end %>
Enter fullscreen mode Exit fullscreen mode
<!--
Add `hx-ext` attribute, wich enables an htmx extension for an element and all its children; in this case the `response-targets`´extension.
-->
<body hx-ext="response-targets">
</body>
Enter fullscreen mode Exit fullscreen mode

HTMX will now also handle non-200 HTTP response codes, wich may come from:

  def create
    @note = Note.new(note_params)

    if @note.save
      render partial: "notes/note", locals: {note: @note}, status: :created
    else
      render partial: "shared/form_errors", locals: {errors: @note.errors.full_messages}, status: :unprocessable_entity
    end
  end
Enter fullscreen mode Exit fullscreen mode

4. Time for C.R.U.D

For update we're gonna use hx-patch and for delete hx-delete.

controllers/notes_controller.rb

  def color
    if @note.update(color: params[:color])
      render partial: "notes/note", locals: {note: @note}, status: :ok
    end

  def destroy
    @note.destroy!
    head :ok
  end
Enter fullscreen mode Exit fullscreen mode

components/note_component.html.erb

<div id="note_<%=@id%>" style="background-color: <%= @color%> !important;">
  <input type="color" hx-target="#note_<%=@id%>" hx-patch=<%= color_note_path(@id) %> hx-swap="outerHTML" hx-vals='js:{"color": event.target.value}' hx-trigger="change">
  <%= button_tag(data: hx(confirm: "Are you sure you wish to delete this note?", delete: note_path(@id), swap: "delete", target: "#note_#{@id}")) %>
</div>
Enter fullscreen mode Exit fullscreen mode

👀 New stuff:

  • hx-trigger: Useful if you want a different event than the by-default one to trigger the request (I.e: click, for <button>). In this case we use change.

  • hx-swap="outerHTML": We've already talked about hx-swap, but this time we swap the HTML response using outerHTML, which replaces the entire target element with the returned content.

  • hx-swap="delete": To remove a note from our view once it has been deleted from the database, the delete value for the hx-swap attribute works perfectly for this purpose because precisely what it does is to delete the target element regardless of the response.

  • hx-confirm: The widely known confirm wich uses the browser’s window.confirm.

🚨 The essential change:

  • hx-vals='js:{"color": event.target.value}': The hx-vals attribute allows you to add to the parameters that will be submitted with an AJAX request. It should be noted that the parameter we want to pass is dynamic since it corresponds to the value that the user selected through <input type="color">. The prefix js: allows us to evaluate this value but we must consider security issues, especially Cross-Site Scripting (XSS) vulnerability.

🪄 A few touch ups.

The devil is in the detail; If we want to achieve a decent UI/UX small features like these cannot be missing:

1. Swap Transitions

<div id="note_<%=@id%>" class="fade-note-out">
  <%= button_tag(data: hx(confirm: "Are you sure you wish to delete this note?", delete: note_path(@id), swap: "delete swap:50ms", target: "#note_#{@id}")) %>
</div>
Enter fullscreen mode Exit fullscreen mode
.fade-note-out.htmx-swapping {
  opacity: 0;
  transition: opacity 50ms ease-out;
}
Enter fullscreen mode Exit fullscreen mode

Do you remember the Modifiers? we added swap: "delete swap:50ms" to fade out "#note_#{@id}" after the DELETE request ends, and then we take advantage of the .htmx-swapping built-in class plus some CSS for the transition we need: opacity: 0;.

2. Keyboard Shortcuts
Here the button that we use to show the modal we use to create/edit notes:

<%= button_tag("New Note", type: "button", data: {"hx-trigger": "click, keyup[ctrlKey&&key=='M'] from:body", "modal-target": "note-modal", "modal-toggle": "note-modal", "hx-get": new_note_path, "hx-target": "#modal-content", "hx-swap": "innerHTML"}) %>
Enter fullscreen mode Exit fullscreen mode

It's necessary overwrite the click event and then we specify the keyup event when Ctrl+M is pressed. The from: modifier is used to listen for the keyup event on the body element. It's a pretty basic TailwindCSS modal, but I want to emphasize again how powerful the modifiers are.

3. Indicators

<div class="htmx-indicator">
  <!-- Some pretty .svg or .gif -->
</div>
Enter fullscreen mode Exit fullscreen mode

.htmx-indicator is a HTMX built-in class that helps us to show spinners or progress indicators while the request is in flight. For our Toy Application, a good place to put it on is _form.html.erb, since users may attach images to the note.

🌠Getting wild with Real-Time

I hope I haven't excited you too much with such a pompous title. Actually it's just a classic search in Real-Time, however I must say that in my opinion this feature in particular is much easier to implement than with Hotwire.

<%= search_field_tag("search", "", data: hx(swap: "outerHTML", trigger: "input changed delay:500ms, search", target: "#notes-grid", post: search_notes_path )) %>
Enter fullscreen mode Exit fullscreen mode

Easy-peasy, All thanks to Modifiers wich allows Triggers to change its behavior. In this case for the changed event we addded the delay:500ms modifier in order to delay sending the query until the user stops typing. That's all it took.

Since we use a search type input we will get an x in the input field to clear the input. We want to trigger a new POST so we add another trigger comma separated. Once again, that simple.

🧰 Resources

🔮Final thoughts

Check this out:


As is say before, HTMX is THE trend, and It makes the frontend more accessible for Backend developers, i like it, still I must point out some limitations that I found during the development of the toy application:

- Capabilities limitations: Almost all attributes and extensions have them, eventually, you will need some JavaScript.
- Steep Learning curve: WHEN COMPARED TO HOTWIRE/TURBO.
- Doesn't suit into the Rails Way: Of course it is not expected to be, but it's a disadvantage compared to Turbo wich, although it's a language-agnostic framework, its integration with Ruby On Rails is natural.
- Early days: It's understood that nothing is born being a superstar just like that, but it has to be said, while everyone is talking about HTMX these days, it still has a small community which makes it difficult to find solutions for problems.

It surpasses me to say whether HTMX is the future or not, what I can and want to do is encourage you to become familiar with it; We must have something clear, HTMX it's an alternative to Hotwire.

Thanks for reading 😊.

Top comments (0)