Escaping the traditional Rails form

Theres been a pattern I’ve seen creeping in Rails apps. The pattern is that there are cases where someone needs to “escape” a form and provide a different action.

For example, lets save I have a form with both a delete button and a save button next to each other for something like a comment. Here’s what that may look like:

<form action="/comments" method="post">
  <label for="content">Content</label><br>
  <textarea id="content" name="comment[content]"></textarea>

Or if you’re using Rails, you may do something like this:

<%= form_with model: do |form| %>
  <%= form.label :content %>
  <%= form.textarea :content %>


  <%= form.submit "Save" %>
  <%= form.submit "Delete" %>
<% end %>

Now you might be thinking these 2 are equal. But they’re not. Under the hood form.submit creates an <input> tag and sets the value to "save" so it looks like this:

<!-- Generated by form.submit -->
<input type="submit" value="Save">
<input type="submit" value="Delete">

Now this is ok, but the problem is <input> doesn’t accept nested HTML tags. So you would need a button if you wanted to show an icon or something similar.

The other problem we’re presented with is if we want to go to a different route or use a different method, we may be tempted to use button_to, but this generates a full form wrapper and forms cannot be nested inside of other forms.

So let’s see how we could generate a button instead to get around these limitations. In addition, submit buttons are far more flexible than submit inputs.

To generate a submit button from a Rails form, instead of form.submit you can use form.button which of course I don’t see anywhere in the Rails documentation for form helpers, but the tag helper is here:

Moving on, lets see what it looks like at full speed:

<%= form_with model: @comment do |form| %>
  <%= form.label :content %>
  <%= form.textarea :content %>


  <%= form.button "Save", type: :submit %>
  <%= form.button "Delete", type: :submit %>
<% end %>

And that’s it! You now have a submit button. By default, Rails generates a <button type="default"> which technically isn’t an allowable type, so to be safe, I just pass in the real type. (Perhaps a PR for this should be made, but thats for another day.)

f.button has the added benefit of accepting a block unlike a f.submit so you could do something like this to show a “save icon” next to the save text.

<%= f.button type: :submit do %>
  <i class="fa-thin fa-floppy-disk"></i>
<% end %>

Good luck doing that with an <input>!

Escaping the form

Now if you’ve been following along this far, you may have noticed that theres no way to disambiguate the two buttons if they’re both going to the same route.

An easy way around this is to provide a name and value attribute to your buttons so they get submitted with the form and you can then on your backend do a params[] check to see what was submitted.

<%= form.button "Save", type: :submit, name: "commit_type", value: "save" %>

<%= form.button "Delete", type: :submit, name: "commit_type", value: "destroy" %>

Now, in your Rails controller you could do:

def update
  if params[:commit_type] == "save"
    # save it!
  elsif params[:commit_type] == "destroy"
    # get rid of it!

Now this is okay but may not be the best way to handle a destroy action since you should have a dedicated route for that. Instead, we can change the formaction on the button to point to go to our delete path.

<%= form.button "Delete", type: :submit, formmethod: :delete %>

This will now override the method set on the form and make it a DELETE request!

Now let’s take it one step further, maybe you need to send the delete request to a different path. You can do so by passing in a formaction to the button like so:

<%= form.button "Delete", type: :submit, formmethod: :delete, action: "/super-secret-url" %>

Alright, maybe you don’t have /super-secret-url but you get the point.

The final thing we can do with a button is submit with an arbitrary form!

For example, let’s say I have a Logout button in my header than needs to submit a delete request to the backend.

Most Rails devs are familiar with the old Rails-UJS way of using a link, but this isn’t really a link like this:

<%= link_to "Log out", "/logout", data: { method: "delete" } %>

This works but you now have a link functioning as a button inside of a form and it is not as clear to screen readers what this link actually does since links are technically only supposed to be GET requests.

What if instead of this, you could have a hidden form on the page and tell the button to submit using that form! It’s 100% possible!

<!-- Forms only technically support GET / POST. Rails does some magic to make it work properly. -->
<form id="logout-form" action="/logout" method="post" class="hidden"><form>

<!-- other stuff -->

  <!-- Its worth noting, formmethod only supports GET / POST, but when submitted with Turbo works some magic to send a DELETE. -->
  <button formmethod="delete" form="logout-form"> Logout </button>

That’s right! You can mix and match formmethod, formaction, and form!

form expects the id of the form you would like the button to submit with! This means you can have a button submit to a form from anywhere in your page without having it inside of the form! Pretty nifty! Buttons are pretty cool!

To read more about buttons, MDN outlines all the cool properties and attributes.

Additional notes

Technically the formmethod of a button only supports post and get. We use delete in this post because Turbo is able to infer the proper formmethod for us and send the appropriate fetch request.

I have not tested if this technique works with Turbolinks or Rails-UJS. I do know it is supported by Mrujs and Turbo.

Anyways, hope this was helpful and stop nesting links in forms especially if you’re using Turbo!

If you are limited by the formmethod issue, it is recommended to construct a <form> using the Rails helpers with the proper method like this:

<%= form_with id: "delete-model", model: @model, method: :delete, class: "hidden" %>

<%= form_with model: @model, method: :put do |form| %>
  <%= form.button "Delete", type: :submit, form: "delete-model" %>
<% end %>

The form attribute will override the form that the button is currently nested in and avoids the limitations of the formmethod. Anyways, happy hunting and I hope this was a useful guide to all the fun ways to use buttons with forms and how to escape the generic nesting a button in a form and unlocks some new UI / UX potential for you!