Diving Deep: Action View Form Helpers

In the previous article, I mentioned that Action View has some component-like classes that render form elements. Let's deep dive into a simple one: Tags::TextField.

This class is used in the FormBuilder like so (source):

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

Note that two things are happening here. First, an instance of Tags::TextField is initialized with the necessary arguments. Then render is called which is what outputs HTML.

Let's take a peek into the class: (source) (I've compressed it and removed some irrelevant stuff for the article)


class ActionView::Helpers::Tags::TextField < Base
  include Placeholderable

  def render
    options = @options.stringify_keys
    options["size"] = options["maxlength"] unless options.key?("size")
    options["type"] ||= field_type
    options["value"] = options.fetch("value") { value_before_type_cast } unless field_type == "file"
    add_default_name_and_id(options)
    tag("input", options)
  end

  # ...
end

The render method has some logic in it. It:

  • adds a few parameters to options like size and type

  • calculates the value the input field should have using value_before_type_cast

  • uses add_default_name_and_id to mutate the options hash to add conventional names and id values

Finally, after doing all of that, it renders the HTML tag using tag("input", options).

A lot of the heavy lifting is done by the parent Tags::Base class and all of its included modules (source: again edited and slimmed down). Here's a sneak peek (don't stress about reading and understanding all of it. The details are not too relevant)

class ActionView::Helpers::Tags::Base
  include Helpers::ActiveModelInstanceTag, Helpers::TagHelper, Helpers::FormTagHelper
  include FormOptionsHelper

  attr_reader :object

  def initialize(object_name, method_name, template_object, options = {})
    @object_name, @method_name = object_name.to_s.dup, method_name.to_s.dup
    @template_object = template_object

    @object_name.sub!(/\[\]$/, "") || @object_name.sub!(/\[\]\]$/, "]")
    @object = retrieve_object(options.delete(:object))
    @skip_default_ids = options.delete(:skip_default_ids)
    @allow_method_names_outside_object = options.delete(:allow_method_names_outside_object)
    @options = options

    if Regexp.last_match
      @generate_indexed_names = true
      @auto_index = retrieve_autoindex(Regexp.last_match.pre_match)
    else
      @generate_indexed_names = false
      @auto_index = nil
    end
  end
end

By heavy lifting, I mostly mean taking all of the necessary measures for the text field to conform to the HTML spec and Rails’ conventions for Action View, Active Record Active Model and Action Pack.

I've dug deep into this for several reasons:

  1. I'm a long-time user and supporter of component libraries. ViewComponent and Phlex provide very interesting solutions for common problems when building views. They were born in the modern era, inspired by libraries like React or Vue which have created very interesting and useful patterns. There's nothing like cross-pollination for new ideas to emerge. And actually, Web Components fit in this bucket as well.

  2. Using these libraries with Rails forms is very hard and up until a few months ago, there were a bunch of unsolved challenges when trying to do so. As an example, the ViewComponent authors had to build a Compatibility module to patch the capture method for this and other use cases.

  3. As we saw in the previous articles, even with Rails' core constructs (FormBuilder, partials etc) it's hard to build flexible form builder methods with nice interfaces.

Describing the problem

Here's my take. Rails form helper classes like Tags::TextField have two different responsibilities.

  1. Build the attributes for an Active Record/Active Model/Action Pack compliant form input and

  2. render that input (i.e. Output the HTML)

The second one is pretty simple in most cases. Just use a tag method and pass the tag name, and what are the arguments: tag("input", options). For some other cases, it's not that simple.

It's the first responsibility that's super difficult to do and it's written to work extremely closely with Rails conventions.

I believe it's this mix of responsibilities that makes it hard for other libraries and even Rails itself to provide a better developer experience when building new, custom form builder methods and helpers.

The solution

Split it. ✂️

I believe that if we split the attribute-building responsibility from the HTML rendering of tag helpers and make the attribute-building a public API, we can provide developers with the power of the current form builder methods without constraining the APIs exposed to the view.

A pseudo-code example

It's really pseudo-code so look past the errors and the poor naming of things 🙏🏽

Let's work with the Tags::TextField class to imagine what that future could look like. As a reminder, this is how it currently looks inside of the FormBuilder class

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

What if instead of a single text_field method delegating to a single class, we had three different methods: one for rendering HTML, one for attribute building (both delegating to specialized classes) and one uses them both that’s exposed as the interface to build input fields:

class FormBuilder
  # the method used when building a form in a template
  def text_field(object_name, method, options = {})
    attributes = text_field_attributes(object_name, method, options = {})
    text_field_element(attributes)        
  end

  def text_field_element(attributes)
    # A new helper method and class responsible for text field renderring
    Tags::TextFieldHTML.render(attributes)
  end

  def text_field_attributes(object_name, method, options = {})
    # A new helper method and class responsible for attribute building
    AttributeBuilder::TextField.build(object_name, method, options)
  end
end

In this example, a developer using form.text_field :title would see no difference. But this opens up a whole world of possibilities. Let's explore some.

Using component frameworks

With something like this, component frameworks like ViewComponent or Phlex wouldn't need to reimplement all of the smarts that Rails has put in place to work with Active Record, Active Model and Action Pack. They can simply focus on the specific logic and markup the developer needs to enhance form elements for their specific use case. Here's some pseudo-code.

class FormBuilder
  def text_field(object_name, method, options = {})
    # leverage the attribute-building part provided by rails
    attributes = text_field_attributes(object_name, method, options = {})
    # but replace the default text field renderer with a component
    component = Components::SuperComplexTextField.new(
      attributes: attributes,
      object: self.object
    )
    @template.render component
  end

  def text_field_attributes(object_name, method, options = {})
    AttributeBuilder::TextField.build(object_name, method, options)
  end
end

In this example, we replace the HTML renderer from Rails' default one with a Component. How does the Component how to render an input field of type text? It can either:

  • inspire itself on the default one and then extend it with any specific details or

  • use the class provided by Rails and add any other markup or logic needed (if the Tag class is also became a public API)

Using partials

Remember the case we talked about in part 1 of this series where you had to come up with weird names for the builder's methods to prevent clashes? Well this approach can solve that

class FormBuilder < ActionView::Helpers::FormBuilder
  def text_field(object_name, method, options = {})
    # build attributes with new builder
    attributes = text_field_attributes(object_name, method, options)
    @template.render(
      "form_builder/text_field", 
      locals: { form: self, 
                method: method, 
                options: options,
                attributes: attributes,
              }
    )
  end
end
<div>
  <span>Some icon</span>
  <!-- Render eleemtn with html helper -->
  <%= self.text_field_element(attributes) %>
  <span><%= self.object.error_messages[method] %>
</div>

Now there's no issue with names and the form builder method can still be named just text_field.

If this has piqued your interest, I'll be publishing one last article that shows what it takes to make this happen. I've already forked Rails and started to understand how to tackle this work; from understanding the many different tags Rails already has, figuring out tests, to envisioning how many phases a project like this one might have.

Stay tuned!