DEV Community

Konnor Rogers
Konnor Rogers

Posted on

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>
  <br>
  <button>Save</button>
  <button>Delete</button>
</form>
Enter fullscreen mode Exit fullscreen mode

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

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

  <br>

  <%= form.submit "Save" %>
  <%= form.submit "Delete" %>
</form>
Enter fullscreen mode Exit fullscreen mode

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">
Enter fullscreen mode Exit fullscreen mode

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:

https://api.rubyonrails.org/classes/ActionView/Helpers/FormTagHelper.html#method-i-button_tag

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

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

  <br>

  <%= form.button "Save", type: :submit %>
  <%= form.button "Delete", type: :submit %>
</form>
Enter fullscreen mode Exit fullscreen mode

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> 
  Save
<% end %>
Enter fullscreen mode Exit fullscreen mode

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" %>
Enter fullscreen mode Exit fullscreen mode

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!
  end
end
Enter fullscreen mode Exit fullscreen mode

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 %> 
Enter fullscreen mode Exit fullscreen mode

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" %>
Enter fullscreen mode Exit fullscreen mode

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" } %>
Enter fullscreen mode Exit fullscreen mode

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 -->

<nav>
  <!-- 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>
</nav>
Enter fullscreen mode Exit fullscreen mode

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.

https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#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 %>
Enter fullscreen mode Exit fullscreen mode

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!

Top comments (4)

Collapse
 
lso profile image
Louis Sommer

Thanks Konnor, so helpful !

Collapse
 
davidteren profile image
David Teren

Very informative post Konnor. Thanks for this.

Collapse
 
swanny85 profile image
Steve

Great post, super mega awesome sauce. Also you can always just use a get for logout if you're using devise and setting config.sign_out_via = :get

Collapse
 
arivero profile image
Alejandro Rivero

You will need a controller with per_form_csrf_tokens = false or generate the masked tokens for each method and action. And this second option I can not see how to do it without javascript. Perhaps multiple authentication_token fields, with some enable/disable trick.