DHH recently announced that Hotwire is going to be included in Rails 7 by default. Due to its incompatibility with Hotwire, Rails UJS is going to slowly be phased out of Rails. This affects more than just JS views and remote forms. It will also remove Rails’ disable
UJS along with method
on link tags and confirm
. Thankfully, a simple implementation of disable
can be re-created using Stimulus.
The tricky part of recreating the disable behavior is getting the interface to be as clean as the original. I actually opened a PR regarding a disable controller earlier this year, and the man himself said as much about the interface (I embarrassingly left the PR stale after getting swept up in my last semester of college).
A major problem was getting the assignment to be a one-liner. The implementation I made a PR with required the manual assignment of targets and actions.
I disliked the idea of manually assigning event listeners in the controller because this made it possible to leave them stranded outside the lifecycle of the Stimulus controller. My hope was that I could find a part or the Stimulus interface that allowed me to programmatically create an action. After some digging through the source code of Stimulus, I came to the conclusion that the only public interfaces in the library were the mutation observers and callbacks to those observers in other classes. As far as I can tell, Sam tried to keep the surface area of the library so small that this was the only entry-point.
With that backstory out of the way, I’ll begin explaining the disable_controller
with the connect
method.
import { Controller } from "stimulus"
export default class extends Controller {
connect() {
this.element.dataset['action'] = 'submit->disable#disableForm'
}
}
Written this way, the connect
method interacts with Stimulus seemingly the only way that we can, through data attributes. Actions are added to the parent element when the controller connects. The actions listen for a submit
event, so this controller is only suitable for forms right now. Regardless, this allows us to achieve the one-liner behavior of the original library.
We can now add custom disable values by leveraging the Values API.
export default class extends Controller {
static values = { with: String }
connect() {
this.element.dataset['action'] = 'submit->disable#disableForm'
if (!this.hasWithValue) {
this.withValue = "Processing..."
}
}
}
We even get a little syntactical sugar since the markup for our value will read disable-with-value
in our markup. Plus, the custom default values allowed in Stimulus v3 will allow the new line in the connect method to be removed as well.
Now we can move to writing the action triggered by the submit event.
export default class extends Controller {
static values = { with: String }
connect() {
this.element.dataset['action'] = 'submit->disable#disableForm'
if (!this.hasWithValue) {
this.withValue = "Processing..."
}
}
disableForm() {
this.submitButtons().forEach(button => {
button.disabled = true
button.value = this.withValue
})
}
submitButtons() {
return this.element.querySelectorAll("input[type='submit']")
}
}
The code is fairly simple, it finds all of the submit inputs in the form, and it disables them. Then it replaces their text. Using a query selector isn’t super Stimulus-esque, but it helps maintain that one-liner interface for the controller.
Now the controller needs to be dropped in on the desired form. Thanks to what happens in the connect method, it takes just a one-liner to hook everything up.
<%= form_with model: registration, data: { controller: "disable", "disable-with-value": "Signing up..." } do |f| %>
<div>
<%= f.label :name %>
<%= f.text_field :name %>
<%= render "shared/error", record: registration, attribute: :name %>
</div>
<div>
<%= f.label :email%>
<%= f.email_field :email %>
<%= render "shared/error", record: registration, attribute: :email %>
</div>
<%= f.submit class: "disabled:cursor-not-allowed" %>
<% end %>
And here's the result! A nice little one-liner that can be dropped in on forms in order achieve a bit of Rails magic that's incompatible with Hotwire.
With all that said, there's a couple of obvious problems with this implementation. I considered working these out before writing about this disable controller, but I decided that it would be best to get this out here first and solicit feedback from the community to see what advice people offer.
The first problem relates to Hotwire's frames and streams. In its current incarnation, this implementation works fine with frames that replace the context that the form is in. Where this falls flat is with frames that target other contexts and streams.
I think that this should be easily rectifiable by listening for the relevant Turbo events, but I won't have time to explore this until the Labor Day weekend.
The second problem is that this controller does not cover all of the applications that the original does. For instance, I believe this should work on a button that is properly dropped into a form. Where this won't work is on isolated buttons and links. The buttons should be easy to add functionality for, but I'm torn about the links.
So I wanted to pitch out a question to community members for that. Should link tags be able to be disabled? The disabled property is not native to links, and the original Rails UJS implementation uses custom attributes to track all this. Particularly, it uses JavaScript to intercept and cancel click events on link tags.
My intuition is that this goes against the spirit of "progressive enhancement" that is part of Hotwire. I'd like to hear your thoughts though, so let me know down in the comments of this post.
Top comments (6)
Great write-up. This is common use case which I'm running into frequently myself. Recently, I started using an alternative approach with a spinner which avoids touching a stimulus controller to create the intended behaviour.
It requires to create a partial for the spinner SVG including a text variable and swapping f.submit events for f.button. It's fast, minimal overhead and reproducable. The only concern I have is if swapping the submit event for a button has other, unintended consequences. Love to hear your thoughts on this approach.
Very cool! How does this circumvent the use of a Stimulus controller though? I suppose it could just use UJS, but UJS being deprecated is why I wrote this up in the first place.
Oh, okay.. I'm using UJS indeed for this.
Nice! I’ll steal this one, to be honest, something this should be the default.
Not sure how many projects have had the double post problem, like, all of them?
Hey! This is actually being added to Turbo by default. I had opened a PR for the behavior, but ultimately Sean came up with a much more simple and elegant solution.
If you have any thoughts be sure to let them know since the PR is still open.
This is exactly why I am so frigging excited about rails lately.
Every time I see stimulus to handle some edge case I simply laugh at how simple life can be.