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 as ActionView::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:

Disabled Button

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.

Published on March 7, 2024