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>
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>
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:
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>
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 %>
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!
end
end
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 -->
<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>
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 %>
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)
Thanks Konnor, so helpful !
Very informative post Konnor. Thanks for this.
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
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.