Advanced Forms (No JavaScript!)

When working with complex forms, it’s really easy to immediately start adding JavaScript to implement non-common behaviors. But there are some hidden gems in the HTML standard that allow us to do a lot of that without adding a single line of JavaScript!

Why no JavaScript?

First of all, let’s see the benefits of using native features instead of JavaScript to achieve similar results:

  • The form will work as expected even if JavaScript is disabled
  • Accessibility is easier to control with attributes in native elements
  • No JavaScript code to maintain
  • No JavaScript for the browser to download/process
  • When we don’t interfere with the normal form submission process, all the Rails magic “just works”

Before we start, we created a sample application showing implementation examples for you to play with.

Multiple Submit Buttons

A common example of this type of form is a listing with multiple actions for selected elements. Imagine an email client where you can check different emails and mark them as read or as unread; or a form to create an element with buttons like save and a save and add a new one variant. This is useful when we have similar actions to be executed but with some variable part depending on the button being pressed.

The JavaScript solution usually involves adding an event listener for each button’s click event, querying the DOM to get the list of selected elements, and then executing the request that the form would normally run.

But there’s a native feature in form elements: when a button tag with the type="submit" attribute is pressed, the request parameters will include information about the specific button. Using this feature, we can add multiple button tags and assign name and value attributes to them to know exactly which button was pressed when processing the request.

If we have this markup:

<%= button_tag "Mark as read", type: "submit", name: "pressed_button", value: "mark-read" %>
<%= button_tag "Mark as unread", type: "submit", name: "pressed_button", value: "mark-unread" %>

We can click the different buttons and our params hash will have the value of the pressed button under the :pressed_button key.

Be careful when naming the button elements. While action may look like a good name for it, it’s a reserved param name used by Rails to identify the current controller and action (controller method).

You can also use input elements with type="submit" but there are some limitations: the param name will be commit by default if unspecified, and the value will always be the text content of the button.

Multiple Submit Actions/Methods

This use case may look similar to the previous one but the difference is that we may want to execute completely different controller actions for different buttons. Imagine an email client where you can select multiple emails from a list and either archive or delete them. We may then have 2 different actions in our controller but only one form to select emails.

Similar to the previous example, a normal JavaScript solution would be to listen to the click event to generate the request to different URLs, but we don’t need JavaScript for that.

We can use the formaction attribute for the button tag with the type="submit" attribute (or input tag with type="submit") to tell the browser that we want to do completely different things when clicking each of them.

<%= submit_tag "Archive" %>
<%= submit_tag "Bulk Delete", formaction: "/some-alternative-url" %>

When clicking the Archive button, it will submit the form to the URL specified in the action attribute of the wrapper form tag; and when clicking the Bulk Delete button, it will submit the form to the /some-alternative-url endpoint.

You can also use the formmethod attribute to specify a different HTTP method to use for the form submission, but there’s a limitation: the only supported methods are "get" and "post", so this can’t be used for RESTful actions like PATCH or DELETE.

Check MDN for more details on the formaction and formmethod attributes.

Elements Outside the <form> Tag

Another common problem happens when we have a complex view with elements in multiple places of the HTML that are hard (or even impossible) to group inside a <form> element. Again, imagine the same email client with a table listing emails, but, because of some design decision and content structure, the table cannot be nested inside the form tag.

In the JavaScript-based solution, there are usually 2 options: one is to listen to the click event in the submit button and generate a request or populate a hidden field with the selected data; another option is to listen to the change event of the elements outside the form and update hidden fields inside the form accordingly.

HTML forms also support this feature out of the box! We only need to set an id in the form element we want to reference and then add the form attribute in the different elements with the form’s id as the value.

<%= form_with url: "my-action", html: {id: "my-form"} do %>
<% end %>

...

<%= check_box_tag "email_ids[]", index, false, form: "my-form" %>

Now, when the form is submitted, the elements with the matching form="..." attribute with the form’s id will be included in the payload!

Check MDN for more details on the form attributes.

Multi-Step Form (Toggle Element)

The use case for this is also common: imagine a long form that consists of multiple groups of elements and we want to display each group at a time instead of the long form all at once.

The common JavaScript solution is to have buttons to allow the user to move through steps and, listening to the click events, hide/show different elements on the page.

The non-JavaScript solution here is a bit more complex because it requires a a few lines of CSS and some hidden elements to be able to apply the style to the right elements.

The general idea is:

  • we’ll use a hidden radio button right before each of the element wrapping each step
  • we’ll use label elements (styled as buttons) for the back/next actions to change which radio button is checked
  • we’ll use a CSS selector to hide all the steps except for the one after the hidden checked radio button

This will be the pattern used for each step:

<%= radio_button_tag "step", "step-name", false, id: "step-name" %>
<div class="step">
  Step X: name
  <%= label_tag "Back", nil, class: "step-back", for: "previous-step-name" %>
  <%= label_tag "Next", nil, class: "step-next", for: "next-step-name" %>
</div>

Important bits to notice for this to work:

  • It’s important to use radio buttons to ensure only one step is active at a time (note that you can use this approach to toggle multiple elements on/off using a checkbox input instead)
  • The order of the elements is important to simplify the CSS (but there are many ways to do the same using more complex CSS if needed)
  • We are using labels instead of buttons because we can associate a label with a radio button using the for attribute without using JavaScript
  • The id attribute of the radio buttons should match the value for the for attribute in the Back/Next labels that take the user to that step

For the first step there will be no Back button and for the last step there will be no Next

Following that pattern, we only need this CSS snippet to make it work:

input[name="step"],
input[name="step"]:not(:checked) + * {
  display: none;
}

This CSS will take care of hiding the radio buttons we use to handle which step to display, and will also hide any element located right next to an unchecked radio button.

There’s an extra benefit of handling the steps this way: you can decide on which step to start by selecting the corresponding radio button when the view is rendered. This is really useful if you want to get the user back to one particular step with form validation errors for example.

Note that you can use the same idea with labels and invisible inputs to toggle parts of a form if you don’t need to hide/show complete steps but only a group of fields.

Nested Forms

Another really common type of complex form is one that handles one element and its associations all in a single form in one request. We have a complete article (Nested Forms in Rails) on that topic explaining the Rails’ conventions to use, different types of associations, and more examples.

In Conclusion

By using these techniques, we can rely on native browser functionalities without adding any JavaScript code. There are many benefits to doing that, and the limitations can be overcome with some JavaScript sprinkles to improve the UX without relying completely on it.

Resources: You can check the sample app with the implementation of these ideas in our GitHub Repo.