Unlocking the power of forms in Rails

I've had this idea in the back of my head for a while that can potentially unlock a bunch of possibilities with Rails' Form Helpers. But to present it, I want to write about what you can do with them today, the limitations and what I want as a developer.

I'll be releasing 3 articles in quick succession over the next few days.

  1. Part 1: An intro/refresher on FormBuilder

  2. Part 2: A deep dive into how FormBuilder and Tag Helper Classes build tags like <input> or <select>. Here we'll uncover what I believe is the opportunity to unlock new stuff.

  3. Part 3: A proposal for a solution with a link to a Rails Fork that's in progress.

You're using a FormBuilder even if you don't know what it is

Most of what I'll mention in this series and some of the examples come from the awesome Rails Guide: Action View Form Helpers

I have to be honest; I haven't written a plain HTML form in many years and I don't really know how to do it from memory any more. Rails form helpers provide a beautiful developer experience while keeping us true to the fundamentals of the web. What a combo!

When I learnt Rails for the first time I wrote forms that looked something like this one.

<%= form_with url: "/search", method: :get do |form| %>
  <%= form.label :query, "Search for:" %>
  <%= form.text_field :query %>
  <%= form.submit "Search" %>
<% end %>

I typed the stuff and a form popped up. However, I never really understood what form was, especially when working with Stack Overflow examples and older code that used just f. What the hell is f? (please, don't use f).

Well, that form is an instance of a class called FormBuilder. The documentation has a great description of this class:

The +FormBuilder+ object can be thought of as serving as a proxy for the # methods in the +FormHelper+ module.

In other words, an instance of FormBuilder, which is initialized when you call form_with, wraps all the different form elements you can use to build a form in nice methods like label, text_field, button that return plain HTML.

Usually, though, we don't have a plain URL to point our form to. More often than not, form_with is called with a model. Like an instance of a @user or @article and this is where the benefits start multiplying.

<%= form_with model: @article do |form| %>
  <%= form.text_field :title %>
  <%= form.text_area :body, size: "60x10" %>
  <%= form.submit %>
<% end %>

The docs continue with the following:

This class, however, allows you to # call methods with the model object you are building the form for.

To understand this, let's zoom in on the text_field method in the example above. Assuming there is an Article model with a title attribute the output of form.text_field :title will be:

<input type="text" 
       value="My Title" />

Without you having to think about it, Rails added the following attributes to the resulting HTML tag:

  • name: with a convention of the model's class and then the attribute in squared brackets

  • id: with the model name first then the attribut ename

  • value

These are crucial for this element's interactions with things like an associated label tag, JavaScript interactions that require a unique ID or generating a conventional and predictable params hash in your controller to manipulate and persist data.

This is the most important part of forms in Ruby on Rails. The framework has already put in place everything you need to build a form specifically catered to a model and to receive the information back from that form in a predictable way.

(Keep this in mind because this is going to be crucial for this whole series.)

Styling form helpers

Directly in a view template

To style form helpers you can use the class parameter and give it some value of a CSS class. In this case, we'll use the class "input"

<%= form.text_field 
      class: "input" %>

But what if all of your forms look the same and you always use the same class?

<%= form.text_field :title, class: "input" %>
<%= form.text_field :subtitle, class: "input" %>
<%= form.text_field :body, class: "input" %>
<%= form.text_field :footer, class: "input" %>

By using a Form Builder

You can also go one level deeper and customize a FormBuilder with the styles you want. To do it though, you need to understand how a FormBuilder method works. Let’s take text_field as an example (Source):

def text_field(object_name, method, options = {})
  Tags::TextField.new(object_name, method, self, options).render

The method receives a few arguments and immediately delegates them to a new instance of a Tags::TextField class and then calls render on it. These Tag classes (which are nested under the helpers folder in the Action View codebase) are kind of equivalent to what we now call a component: a class whose purpose is to render a bit of HTML.

Without going any deeper, we can just use our own FormBuilder, modify the arguments to add our input CSS class and call super so Rails keeps doing its job.

class SomeCustomFormBuilder < ActionView::Helpers::FormBuilder
  def text_field(object_name, method, options = {})
    options.merge!(class: "input")
    super(object_name, method, options)

This method is fine for simple interventions like adding a few classes and it cleans up your code quite a bit. It also makes it easy to change your code in a single place and affect every form.

<%= form.text_field :title %>
<%= form.text_field :subtitle %>
<%= form.text_field :body %>
<%= form.text_field :footer %>

More complex form elements

But form elements can become very complex. You may want to add a trailing and/or leading icon, a hint, an error message, or complex markup to create a checkbox that looks like a switch. Here are a few ways to solve that

Complex form builder methods

\There's going to be a bit of pseudo-code in this section to make my point so it may contain some errors.*

You can create markup directly in the form builder like so:

class SomeCustomFormBuilder < ActionView::Helpers::FormBuilder
  def text_field(method, **args)
    @template.capture do
      @template.concat super(attribute, args.merge(class: 'a long string of classes if you use tailwind'))
        if some_var = args.fetch(:some_argument, nil)
          @template.concat @template.content_tag(:p, some_var., class: 'another long string of classes')

Even though this option works, it's super complex and hard to read. First, you need to be aware that you're writing stuff to a @template instance variable to which you send the method capture to encapsulate more HTML that you then have to concat. This is not a nice way to write markup at all.

Delegating to a partial

To solve the above, you can just render a partial and create your markup there:

class SomeCustomFormBuilder < ActionView::Helpers::FormBuilder
  def text_field(method, **args)
      locals: { form: self, method: method, args: args)
  <span>Some icon</span>
  <%= self.text_field %>
  <span><%= self.object.error_messages[method] %>

But there's a problem here. I'm calling self.text_field in the template and that will reference the text_field method in the form builder which will create an infinite loop and a stack overflow error.

To solve it you have two options. You either create an <input> field manually and lose all of the work Rails has done to add an id, name and value or rename the text_field method in SomeCustomeFormBuilder by prefixing it with something else like... some_custom_<method_name> so that we can freely call text_field. Let's take that second option:

class SomeCustomFormBuilder < ActionView::Helpers::FormBuilder
  def some_custom_text_field(method, **args) # 👎
      locals: { form: self, method: method, args: args)

It's not bad. But it isn't great either. For a framework and a language that excels at ergonomics and naming, this is not very nice. This is how you would use this in a form:

<%= form_with model: @articel do |form| %>
  <%= form.some_custom_text_field :article %>
<% end %>

What I want

I just want to be able to call form.text_field :article and have it return whatever markup I want. For some people that might be just an <input type="text"> but for others that might be a full-on component with labels icons, hints, errors etc.

Finding the problem

I've done some digging and I think I know what's forcing developers to do this. Now that you know all about Form Builders, in the next article we'll dive even deeper into how something as simple as a TextField is rendered and we'll uncover some coupling that I believe is holding form builders up.