๐ 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
2. Installing the dependency:
Pin the dependency to the importmap:
bin/importmap pin htmx.org
and then import it on your app/javascript/application.js
:
import "htmx.org"
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.
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"
๐ ๏ธ 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 %>"}'>
๐ช 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 %>
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 %>
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 %>
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 %>
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.
}
});
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 %>
<!--
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>
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
4. Time for C.R.U.D
For update we're gonna use
hx-patch
and for deletehx-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
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>
๐ 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 usechange
.hx-swap="outerHTML"
: We've already talked abouthx-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, thedelete
value for thehx-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โswindow.confirm
.
๐จ The essential change:
-
hx-vals='js:{"color": event.target.value}'
: Thehx-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 thevalue
that the user selected through<input type="color">
. The prefixjs:
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>
.fade-note-out.htmx-swapping {
opacity: 0;
transition: opacity 50ms ease-out;
}
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"}) %>
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>
.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 )) %>
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
- Hotwire / Turbo โก๏ธ htmx Migration Guide
- Official Website
- Discord Server
- VS Code Extension
- Subreddit
- HTMX vs. Hotwire discussions
๐ฎ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)