Hotwire Your Buttons
Turbo + Stimulus to disable a button the right way
At some point everyone bumps into the age old problem of unintentional multiple form submissions usually caused by repeated pressing of submit buttons. Fortunately, if you are using Turbo you get the automatic disabling of buttons for free right out of the box.
While Turbo is great at providing these sort of universally helpful features, what makes the Hotwire framework so powerful are the ways that you can easily add more functionality using Turbo attributes and event listeners along with Stimulus controllers to plug up any holes that might be missing from the default functionality. In this blog post I will share some ways in which we ‘Hotwired’ the disabling and enabling of buttons in one of our Rails apps with a little help from TailwindCss and ViewComponents.
The first limitation that we ran into with Turbo disabling of buttons is that it re-enables the button right before redirecting after a form submission which still gives time for a user to click the submit button again and can be a bit of a confusing experience. This issue was reported here and it seems unclear whether this is considered a bug or not but fortunately the temporary solution offered has been effective for us:
document.addEventListener('turbo:submit-end', (event) => {
if (event.detail.fetchResponse.response.redirected === true) {
event.target.querySelectorAll('[type=submit]').forEach((button) => {
button.disabled = true;
});
}
});
However, just disabling a button can also be a bit of a confusing message to users so let’s style that button and even throw in a spinner so the user knows that something is really happening. For our form elements we are making use of a great gem, view_component-form which in their words:
“provides a
FormBuilder
with the same interface asActionView::Helpers::FormBuilder
, but using ViewComponents for rendering the fields. It’s a starting point for writing your own custom ViewComponents.”
Here is what our ButtonComponent
looks like:
# frozen_string_literal: true
module Form
class ButtonComponent < ViewComponent::Form::ButtonComponent
include IconWrapping
include ButtonStyling
def initialize(form, value, options = {})
@value = value
@button_type = options.delete(:type).to_s
@classes = options.delete(:class)
@button_size = options.delete(:button_size)
@hide_spinner = options.delete(:hide_spinner)
super(form, value, options)
end
def call
icon = IconComponent.new(
name: 'spinner',
classes: 'hidden group-disabled:inline-block animate-spin self-center'
)
button_tag(render(icon) + (content || value), options)
end
end
end
There is a bit of noise you can ignore in there but the heart of the disabling with the spinner happens by adding one of our custom IconComponent
ViewComponents with the classes hidden group-disabled:inline-block animate-spin self-center
. Since we are injecting that spinner icon (which is just a simple inline svg) into our button, if we put a group
class on our button then whenever our button gets disabled the spinner will switch from display: hidden
to display: inline-block
thanks to the handy group-
modifier provided by Tailwind. Similarly, animate-spin
is a succinct utility class provided by Tailwind that adds the css animation
and @keyframes
to get our simple spinner icon spinning.
Our ButtonStyling
concern contains the heart of the styling of our different button variations:
module ButtonStyling
attr_reader :button_type, :classes, :button_size, :hide_spinner
def html_class
class_names(
base_classes,
type_classes,
size_classes,
classes,
spinner_class
)
end
def base_classes
'inline-flex gap-2 justify-center align-center font-bold rounded w-full
border-2 text-base text-center appearance-none disabled:pointer-events-none
disabled:bg-gray-400 disabled:border-gray-400 disabled:text-text-secondary'
end
def type_classes
case button_type
when 'hidden'
'hidden'
when 'secondary'
[
'text-primary-700 hover:text-primary-600 active:text-primary-700',
'border-primary-700 hover:border-primary-600 active:border-primary-700',
'hover:bg-primary-50 active:bg-primary-50'
]
else
[
'text-white',
'border-primary-600 hover:border-primary-700 active:border-primary-800',
'bg-primary-600 hover:bg-primary-700 active:bg-primary-800'
]
end
end
def size_classes
case button_size
when 'small'
'py-2.5 px-4'
else
'py-3 px-6'
end
end
def spinner_class
'group' unless hide_spinner
end
end
Tailwind easily handles the styling of the button when disabled and we add the group class to get our animated spinner to look something like this:
What if we just want to disable a button and don’t want to confuse our user with a spinner? For example, perhaps we want to prevent the user from even submitting the form until they have selected something on the page. We have that scenario covered as well with the hide_spinner
option which simply skips adding the group
class to the button.
However, we will then want to enable the spinner once the form submission and its button become active. Enabling the button is equally simple by marking our save button as a target in our form_controller.js
stimulus controller that we add to just about all of our form elements and adding back the ‘group’ class:
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ['saveButton']
...
enableButton() {
if (this.hasSaveButtonTarget) {
this.saveButtonTarget.disabled = false
this.saveButtonTarget.classList.add('group')
}
}
disableButton() {
if (this.hasSaveButtonTarget) {
this.saveButtonTarget.disabled = true
this.saveButtonTarget.classList.remove('group')
}
}
}
However, what if the conditions for enabling the button are really outside of the functional scope of the form that contains the button? We would prefer to not have to wrap the whole page in a Stimulus controller just to be able to contain all the elements that might need to communicate with our form and button.
Fortunately, there is a convenient way for elements to communicate across Stimulus controllers by using global events. Is we add a window data-action value of enable-button@window->form#enableButton disable-button@window->form#disableButton
then we can simply run the following code from any other Stimulus controller:
window.dispatchEvent(new CustomEvent('enable-button'));
and our saveButton
will be triggered thanks to the window event listener that Stimulus graciously “hotwired” for us.
Disabling buttons is a great illustration of how the Turbo and Stimulus components of the Hotwire front end framework complement each other and provide for rich user experiences with little effort. Turbo comes equipped with some basic functionality that can then be customized and built upon using lifecycle event listeners and sprinkles
of Stimulus actions when that extra bit of logic is needed to complete a component or feature in your app.
If you’re looking for a team to help you discover the right thing to build and help you build it, get in touch.