Disable-With Using StimulusJS
Tags: rails, stimulus, railsujs, rails7
Published: 2021-09-03 23:08:12

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](https://github.com/hotwired/stimulus-rails/pull/29) (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.

```js
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. 

```js
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.

```js
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. 

```erb
<%= 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.

![Functioning disable controller](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/c2j2oe24y66xonx3fmhm.gif)

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.