Ok, first thing: I'm mainly a backend developer. That means that in my day-to-day job, I never have to deal with any front-end stuff.
But still, on my many side projects (that no one except me and my cats is lucky enough to play with), I'm always trying to learn new front-end stuff.
When I say frontend stuff, I'm not talking about React, Vue, or whatever crazy/complex/new javascript framework is in trend now. I'm talking about the dumbest possible way to, with a minimal amount of effort, provide a nice user experience.
I wanted for a while to try to implement a shop-like product search with dynamic filtering to check how easy it would be to implement this using mainly server-side rendering. But god knows how lazy am I: Having to make the scaffolding, a good-looking UI (Nice job Louis), and some models to play with.
I recently stumbled on an awesome blog post from Louis Sommer where Louis does exactly this!
Reading the article and the source code, I learned a ton of stuff, as always. In his implementation, Louis is using StimulusReflex (built on top of Stimulus) to achieve this. I was curious about several points:
The UI updates are rendered using Websockets. The flow is like this: You click on something, and the library then uses CableReady to send the request (or to trigger what is called a Reflex) and then pushes the UI update from the server to the client through the Websocket. Ok, it works, but it seems pretty complex to me. Call me a noob, but I thought Websockets was used to push updates to clients in reaction to what happens on the server, not on the client side.
The model used for the filtering concept is using the all_futures gem that I never heard about it. I read the code making use of it and I couldn't find why this library was used: It seemed that everything done with it could be achieved using only ActiveModel.
I learned the hard way to stay away as much as possible from external dependencies and to always try to achieve what I need using mainly my default toolbox. How many times have I been bitten by dependencies not being maintained preventing me to have a smooth Rails upgrade experience? Oh god, too many times! Sure, it's not always possible and it's a matter of balance.
Also, installing StimulusReflex seems quite not easy for the moment: It seems there are some quirks along the way if you're using import-maps for managing javascript dependencies as I do. Embracing the Rails way at least prevents you from this sort of issue.
And when it comes to running/deploying a Websocket server, I'm scared: I never had to, and I don't want to have to deal with that if possible. (I'm sure it's not that complicated in the end, but it's a cognitive tax you add to your workflow).
The implementation
First, I created a minimal rails app using the following command line:
rails new search --database=postgresql --skip-action-mailer \
--skip-action-mailbox --skip-action-text \
--skip-action-cable --skip-jbuilder --css=tailwind
Backporting
I added a compose file for Postgres and backported the migrations, models and artworks. At this stage, I was able to run the seeds and to play with the models in the console, nice!
The next step was about backporting the templates, adding Pagy gem for handling pagination and creating the controller. I was then able to show the listings with the models, but the filtering was not working.
A first old-school version
My first idea then was to, before having it all wired using stimulus, have a working version that could work without even javascript enabled, Web 1.0 style. Sure, who browses with javascript disabled except Richard Stallman? But still, I think it's a good practice anyway to follow: First, implement it this way, and only after, introduce the javascript sprinkles. It's called progressive enhancement, and it's the philosophy behind stimulus.
Making it work that way was surprisingly easy: I had to make the pagination work, adapt the filter, wire things up in the view and voila! A fully working version, no javascript needed. Even Stallman could order from this store!
Hotwire and friends
Then comes the last step: Check how it could work with Hotwire only. And once again it was surprisingly easy: 12 lines of custom javascript!
First, I wrapped the whole form in a <turbo-frame>
tag. That means that by default, every link clicked, every form submitted inside the frame will be captured, and the same frame in the server response will be extracted and will replace the existing content: neat! The frame has a data-turbo-action="advance"
data attribute. This makes the frame's navigation context to update the browser history as if it would have been a full page refresh.
Then we need that every form element interaction automatically submits the form. This is where we need stimulus: I created a stimulus controller, and tied the form dom element to it using data-controller="listings" data-listings-target: "form"
.
The target here is to hold a reference about the form element in the controller so that we can programmatically submit it when needed.
Then, I added controller actions using data-action="listings#refresh"
to every form element: the search input and the filters. Basically, it means that, on change on these controls, the refresh
method of the stimulus controller will be called. In this function, we submit the form using the reference we have (this.formTarget
). Here is the controller we have:
import { Controller } from "@hotwired/stimulus"
import { useDebounce } from "stimulus-use"
export default class extends Controller {
static targets = ["form"]
static debounces = ["refresh"]
connect() {
useDebounce(this, { wait: 100 })
}
refresh() {
this.formTarget.requestSubmit()
}
}
You can see that I added a dependency here: stimulus-use.
That's a very nice library of composable behaviors that you can use to spice-up your controllers. I needed to debounce the refresh method because for the slider form controls, they were triggering the submission for every damn increment. Drop this library, write 2 lines of code and done!
I updated a bit the filter so that I had to deal with only one parameter instead of two.
Also, <select>
form elements are almost impossible to style, they look ugly by default. That's why Louis used a different technique using a dropdown menu. (Notice how it's implemented, without any javascript needed thanks to Tailwind group feature, awesome!). For this one, it works out of the box as I'm using links there.
For the form reset link at the bottom, I had to cheat a bit: Impossible to use the form reset()
method as it resets the form to the last state it had when retrieved from the html. Resetting a form is not the same as clearing it! For this one, I simply used a link to the current page, without any parameters. Job done.
Final touch
In the previous step, before adding Hotwire to the party, I added a button so that a user can submit the form.
I wanted this button to be hidden when javascript is enabled. Sure, easy: just a javascript line dropped somewhere and add a hidden
class or something. Yeah, quite not: Sure it works, but you have this nasty experience where when the browser renders the page, you see the button and then it disappears: it sucks.
I found a nice technique: by default, you have the element disabled. The trick is that, if js is not present you take advantage of the <noscript>
tag to add additional css rules to make the element visible. And that works without any flickering:
<noscript>
<style type="text/css">
button.js-hide { display: inline; }
</style>
</noscript>
Conclusion
I really had a lot of fun implementing this! I love what Rails provides out of the box and how far you can go with it.
I'm sure that StimulusReflex can shine also and could perform better than Hotwire on some specific real-world use cases, but I'm not sure which they are. Also, I'm pretty sure that in this very same scenario, StimulusReflex is slightly more performant as fewer bytes are transiting on an already opened network connection.
Kudos also to Louis for sharing, I can't wait for the next part of his blog post that is about using ElasticSearch. (And replacing StimulusReflex with turbo stream actions, something new to me)
Sidenotes
- If you want to know more about StimulusReflex vs Hotwire, be sure to check this awesome blog post series: Reactive Rails in context
- In the process, I learned about the Pagy gem: I always used Kaminari for pagination. Pagy is awesome and very performant, take a look at it if you don't know it already, highly recommended.
- Louis takes nice pictures.
Top comments (1)
Thanks for the love Fred ! Agree with you on all points, which I was planning to address in the 3d episode of this series of posts :)