Are you absolutely sure you know how to use the button element?

Steve Polito

I used to think the <button> element could only exist inside a <form> and that it did not support any attributes. In other words, I basically thought it was a more semantic version of <input type="submit">.

However, after working on a payment form for a client project, I realized that the <button> element is a robust, versatile and under utilized tool. Plus, knowing how to leverage forms and buttons will benefit you when using Turbo and Stimulus.

Our base

Here’s a traditional Rails form that we’ll be working with in this tutorial. It’s nothing special, and the only thing we need to focus on for now is the fact that the form.button is located within the form per usual. That’s about to change.

<%= form_with model: payment,
      class: "contents" do |form| %>

  <div>
    <%= form.label :amount %>
    <%= form.number_field :amount, required: true %>
  </div>

  <div>
    <%= form.label :payment_method %>
    <%= form.collection_radio_buttons :payment_method_id,
          PaymentMethod.all,
          :id,
          :display_name do |builder| %>
      <div>
        <%= builder.label do %>
          <%= builder.radio_button required: true %>
          <%= builder.text %>
        <% end %>
      </div>
    <% end %>
  </div>

  <div>
    <%= form.button "Pay Now" %>
  </div>
<% end %>

A simple example

Imagine our designer wants the “Pay Now” button to be fixed to the bottom of the screen in an effort to make it stand out. In order to do this, we’ll need to actually place the button outside of the form.

An image of a payment form. The "Pay Now" button is fixed to the bottom of the
screen.

You might be thinking the only way we can still submit the form with the same button is to use JavaScript, but you’d be wrong. The solution is quite simple.

All we need to do is leverage the form attribute.

The value of this attribute must be the id of a <form> in the same document. (If this attribute is not set, the <button> is associated with its ancestor <form> element, if any.)

--- a/app/views/payments/_form.html.erb
+++ b/app/views/payments/_form.html.erb
@@ -1,4 +1,5 @@
 <%= form_with model: payment,
+      id: dom_id(payment),
       class: "contents" do |form| %>
-
-  <div>
-    <%= form.button "Pay Now" %>
-  </div>
 <% end %>
--- a/app/views/payments/new.html.erb
+++ b/app/views/payments/new.html.erb
@@ -5,3 +5,8 @@

   <%= link_to "Back to payments", payments_path %>
 </div>
+
+<%= button_tag "Pay Now", form: dom_id(@payment) %>

In order to ensure we map to the correct id, we utilize the dom_id method on both the form and button. However, we could have just hard-coded an id if we wanted to.

Additionally, we can confirm that hitting the Return key when an element in the form is focused will still submit the form, even though it no longer contains a submit button.

A demo of submitting the form via keyboard navigation. Hitting the "Return"
key submits the
form.

A complex example

Now imagine our designer comes back with a request that would allow a user to delete payment methods on the same form.

Here are a few ways we could achieve this:

  • Use the link_to method with data: { turbo_method: :delete }.
  • Add hidden fields to the form and set their values to the IDs of the payment methods that should be deleted.

Although these are both acceptable solutions, there’s a more semantic alternative. Instead, we can easily accomplish this task by leveraging more button attributes.

--- a/app/views/payments/_form.html.erb
+++ b/app/views/payments/_form.html.erb
@@ -26,6 +26,12 @@
           <%= builder.radio_button required: true %>
           <%= builder.text %>
         <% end %>
+        <%= form.button "Delete #{builder.object.display_name}",
+              formaction: payment_method_path(builder.object),
+              name: "_method",
+              value: "delete",
+              formnovalidate: true,
+              data: {turbo_confirm: "Delete #{builder.object.display_name}?"} %>
       </div>
     <% end %>
   </div>

So, what’s going on here? The formaction attribute simply overrides the parent form’s action. There’s no need to set a formmethod attribute because the parent form’s method is already set to "post".

The name and value attributes are passed as form data to the controller. We do this to ensure we can make a DELETE request. Here’s what the request looks like:

{
  "authenticity_token"=>"123abc",
  "payment"=>{"amount"=>"100", "payment_method_id"=>"1"},
  "_method"=>"delete", #<- "name" => "value"
  "action"=>"destroy",
  "id"=>"1"
  "controller"=>"payment_methods",
}

You can almost think of this button as a form similar to the following:

<form action="/payment_methods/1" method="post">
  <input type="hidden" name="_method" value="delete">
  <button>Delete Payment Method</button>
</form>

However, we can further simplify this implementation by leveraging the formmethod attribute. Although this attribute normally only supports post, get, and dialog, the form.button is able to support delete just like Rails’ forms.

--- a/app/views/payments/_form.html.erb
+++ b/app/views/payments/_form.html.erb
@@ -28,8 +28,7 @@
         <% end %>
         <%= form.button "Delete #{builder.object.display_name}",
               formaction: payment_method_path(builder.object),
-              name: "_method",
-              value: "delete",
+              formmethod: :delete,
               formnovalidate: true,
               data: { turbo_confirm: "Delete #{builder.object.display_name}?" },

You might be wondering why we set the formnovalidate attribute. We can demonstrate what it does by temporarily having it removed. You won’t be able to delete a payment method if the rest of the form is invalid.

An image of an invalid form. Hitting a button to delete a payment does not
work, and instead validates the payment form. The "Amount" field has an error
reading: Please fill out this
field.

There is one subtle issue with the current state of our form, but it’s a big one: Hitting the Return key does not submit the form. Instead, it triggers one of the delete payment method buttons.

A video of the form being navigated with a keyboard. Hitting the Return key
unexpectedly triggers the delete payment method
button

This is because hitting the Return key will use the first submit button in the form. In this case, that happens to be a button to delete a payment method. To fix this, we can simply add back the submit button, this time at the top of the form. Since we don’t want two “Pay Now” buttons, we can simply make it hidden.

--- a/app/views/payments/_form.html.erb
+++ b/app/views/payments/_form.html.erb
@@ -13,6 +13,8 @@

+  <%= form.button "Pay Now", hidden: true %>
+
   <div>
     <%= form.label :amount %>
     <%= form.number_field :amount, required: true %>

In other words, we are making this button inaccessible by design by removing its semantics from the document.